101 Commits

Author SHA1 Message Date
GatlinHa
33fe4936b4 bug fixed 2025-05-30 22:28:21 +08:00
GatlinHa
70afd3ae7e bug fixed 2025-05-30 18:57:05 +08:00
bob
048adef4ea bug fixed 2025-05-21 15:38:28 +08:00
bob
44ff5b0134 更新readme 2025-05-21 12:13:55 +08:00
bob
01195ffebe 更新版本和readme 2025-05-21 11:30:57 +08:00
bob
0a12d37778 样式调整 2025-05-20 21:45:38 +08:00
bob
a011501089 历史消息功能 2025-05-20 21:16:32 +08:00
bob
ddbf6614e7 文件名重构 2025-05-18 15:42:48 +08:00
bob
16b46fe6f8 文件名重构 2025-05-18 15:41:29 +08:00
bob
890c1f9fd6 音频消息样式下载优化 2025-05-18 15:33:21 +08:00
bob
88bc4f3efb 转发消息缺少提示音 2025-05-18 15:04:10 +08:00
bob
5c0b255092 变量名重构 2025-05-18 12:28:31 +08:00
bob
e3d01fc035 image viewer插入到body上面 2025-05-18 12:26:21 +08:00
bob
9880f813f8 复制粘贴包含媒体对象的消息时,避免使用本地对象ID 2025-05-17 22:44:57 +08:00
bob
a2017be96a msg.content消息结构重构 2025-05-17 12:21:50 +08:00
bob
ecfa623a72 bug fixed 2025-05-13 17:38:03 +08:00
bob
e63ac508f3 聊天记录dialog自定义关闭按钮 2025-05-13 17:15:39 +08:00
bob
ad0d0ad7a0 imageInSession的结构重构 2025-05-13 16:43:16 +08:00
bob
653bbccb24 清除冗余代码 2025-05-13 15:31:01 +08:00
bob
748423b2b9 发送失败的消息不能被多选中 2025-05-13 15:25:56 +08:00
bob
5ac2d69c43 消息发送失败只做本地删除 2025-05-13 15:18:56 +08:00
bob
da93d05d67 renderForwardTogether挂载成功回调 2025-05-13 15:13:24 +08:00
bob
83376a2a6a 合并消息转发重构 2025-05-13 15:01:31 +08:00
bob
fd5a645bba 合并转发 2025-05-12 21:18:21 +08:00
bob
7618b68713 jxp动态组件插件 2025-05-12 21:12:12 +08:00
bob
b8dd1a8d04 bug fixed 2025-05-08 21:13:53 +08:00
bob
9db1dc2c3a bug fixed 2025-05-08 20:39:26 +08:00
bob
76dfcecf2b 样式微调 2025-05-08 16:37:04 +08:00
bob
673dcd044b bug fixed 2025-05-08 16:13:41 +08:00
bob
ea8dabb9d8 bug fixed 2025-05-08 16:06:00 +08:00
bob
edf5fe4418 逐条转发 2025-05-08 15:55:45 +08:00
bob
93a66e760f "消息已删除"消息不能被多选中 2025-05-08 15:25:33 +08:00
bob
d9cc186fc1 bug fixed 2025-05-08 12:03:08 +08:00
bob
92e73fe094 多选模式2:消息批量删除 2025-05-08 12:01:40 +08:00
bob
b1039b9c6f 禁用反选 2025-05-08 09:50:02 +08:00
bob
e187025d4e 多选模式下系统消息,撤回消息禁选 2025-05-08 09:48:00 +08:00
bob
6673804208 离开群组后,不能撤回消息 2025-05-08 09:32:44 +08:00
bob
0f2167015e 非输入框模式不能重新编辑和引用 2025-05-08 09:23:57 +08:00
bob
75d37d0ceb 多选模式1 2025-05-08 09:17:27 +08:00
bob
94bfe0e957 更新readme 2025-05-07 14:39:47 +08:00
bob
98b66780c3 实现消息转发 2025-05-07 12:21:42 +08:00
bob
5763438082 组件名重构 2025-05-06 12:37:02 +08:00
bob
6e7e72d92d 实现引用消息 2025-05-06 12:18:43 +08:00
bob
84b113eb44 更新readme 2025-04-30 10:29:16 +08:00
bob
958be10181 bug fixed 2025-04-30 09:43:36 +08:00
bob
e357429d3d 删除消息要同步到本人其他账号 2025-04-29 23:19:20 +08:00
bob
240b0560dc 更新version 2025-04-29 21:11:36 +08:00
bob
58e46fa09f 实现消息删除功能 2025-04-29 18:02:11 +08:00
bob
f88b6a0388 实现消息撤回功能 2025-04-29 16:44:00 +08:00
bob
ea1e74fd08 样式调整 2025-04-28 15:40:07 +08:00
bob
41947e9060 语音消息不能复制 2025-04-28 15:21:34 +08:00
bob
0fd2a4d4cb 支持复制媒体消息 2025-04-28 14:59:59 +08:00
bob
81fdfda734 1. 发送文件前弹窗提醒;2. 复制粘贴消息渲染 2025-04-28 12:18:42 +08:00
bob
f8a949b4a8 给消息添加右键菜单 2025-04-27 16:19:24 +08:00
bob
bfecd911fe 实现清屏效果 2025-04-27 15:39:00 +08:00
bob
e500498b9e store总线控制只显示一个菜单 2025-04-27 15:32:07 +08:00
bob
4af374f703 函数名重构 2025-04-27 15:14:35 +08:00
bob
90b2144c6e 文件名重构 2025-04-27 14:21:34 +08:00
bob
9c8c4d905b 样式微调 2025-04-27 11:31:26 +08:00
bob
9ff8e82eb9 优化前端搜索 2025-04-27 11:03:14 +08:00
bob
b0d085b36c 更新readme 2025-04-27 10:15:06 +08:00
bob
5237d3044d 更新version 2025-04-27 10:01:14 +08:00
bob
b19bd8e05f 实现@功能 2025-04-25 21:12:09 +08:00
bob
7791d34f30 文件名重构 2025-04-21 17:03:56 +08:00
bob
9a04c5f86d getQuill()改成计算属性 2025-04-21 16:42:24 +08:00
bob
22e95f59bb 消息编辑和消息显示换行 2025-04-21 16:36:01 +08:00
bob
10c47f4389 更新1.2.0版本号 2025-04-18 10:02:58 +08:00
bob
1aab44784a bug fixed 2025-04-17 20:44:26 +08:00
bob
93665b14c8 图片消息整体背景为黑色 2025-04-17 17:06:08 +08:00
bob
cf6f31e45e 图片和视频加载错误显示 2025-04-17 16:18:39 +08:00
bob
0b977df45b 文档消息自定义下载 2025-04-17 15:36:12 +08:00
bob
959cbaf394 上传失败的不重发 2025-04-17 10:03:17 +08:00
bob
6d88195590 文件读取异常给出提示 2025-04-17 09:57:38 +08:00
bob
8ed65c1358 Image加载完成前的默认渲染处理 2025-04-17 09:42:06 +08:00
bob
96f15c1022 video加载完成前的默认渲染处理 2025-04-16 21:36:13 +08:00
bob
6d9ec0e260 bug fixed 2025-04-16 17:09:25 +08:00
bob
43c7dc0532 文件上传失败时,消息标记为failed 2025-04-16 16:14:24 +08:00
bob
dda01d9bb9 文件上传失败 提示优化 2025-04-16 15:34:53 +08:00
bob
6648805595 已读消息逻辑优化 2025-04-16 13:02:12 +08:00
bob
35dfd305a7 bug fixed 2025-04-16 09:28:57 +08:00
bob
a20f71ab9f 通过预签名URL上传文件时设置'Content-Type' 2025-04-15 22:00:50 +08:00
bob
e5be8bd266 文件上传逻辑清除冗余代码 2025-04-15 16:32:27 +08:00
bob
b1c51774e3 重构:用预签名URL上传文件 2025-04-15 16:20:33 +08:00
bob
2c1393e298 输入MIX模式下的图片太小可以保持原尺寸 2025-04-11 12:14:34 +08:00
bob
bccf1e1844 URL.createObjectURL需要释放 2025-04-11 11:56:36 +08:00
bob
cfd8c9cc16 QuillEditor优化 2025-04-11 10:58:41 +08:00
bob
1a043add28 未登录提示优化 2025-04-11 09:19:07 +08:00
bob
5db0b07be5 修改头像添加限制类型 2025-04-10 16:50:58 +08:00
bob
4d1f8cc1c2 简化头像修改动作 2025-04-10 16:47:26 +08:00
bob
390d983986 释放Blob URL 2025-04-10 16:36:51 +08:00
bob
cd6864587a 不需要设置跨域头 2025-04-10 16:05:00 +08:00
bob
51a5fc5445 无法标注消息已读 2025-04-10 12:12:48 +08:00
bob
2e8c834fe6 同一session中共用一个preview-src-list 2025-04-10 09:28:32 +08:00
bob
34a4ad40e8 handleLocalMsg时消息拉到底部 2025-04-09 16:20:38 +08:00
bob
bda15e13e4 发送逻辑优化 2025-04-09 15:56:54 +08:00
bob
026065f2f1 消息发送立即渲染逻辑重构 2025-04-09 11:17:57 +08:00
bob
06750ddf78 callback替代watch 2025-04-08 23:42:34 +08:00
bob
351562d02d bug fixed 2025-04-08 21:45:02 +08:00
bob
304c1864e0 样式微调 2025-04-08 20:40:53 +08:00
bob
f8f0fe3790 清理冗余代码 2025-04-08 20:30:06 +08:00
bob
b39e38de4a 发送时立即渲染本地消息 2025-04-08 20:28:22 +08:00
100 changed files with 8151 additions and 1422 deletions

View File

@@ -9,7 +9,10 @@ module.exports = {
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
ecmaVersion: 'latest',
ecmaFeatures: {
jsx: true
}
},
rules: {
'prettier/prettier': [
@@ -27,6 +30,7 @@ module.exports = {
{
ignores: ['index'] // vue组件名称多单词组成忽略index.vue
}
]
],
'vue/jsx-uses-vars': 'error' // 确保 JSX 中使用的变量被正确识别
}
}

View File

@@ -4,5 +4,6 @@
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
"trailingComma": "none",
"jsxBracketSameLine": true
}

View File

@@ -42,10 +42,12 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
- [x] 多端在线
- [x] 多端同步
- [x] 已读未读
- [ ] 历史消息
- [ ] @消息
- [ ] 消息撤回
- [ ] 消息引用
- [x] @消息
- [x] 消息撤回
- [x] 消息删除
- [x] 消息引用
- [x] 消息转发
- [x] 历史消息
- [ ] 消息加入待办
#### 群组功能
@@ -57,8 +59,6 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
- [x] 群公告
- [x] 群系统消息
- [x] 群转让
- [ ] 组织群
- [ ] 公开群
#### 通讯录功能
@@ -66,7 +66,6 @@ Open AnyLink是一款面向企业的IM即时通讯解决方案旨在帮助企
- [x] 联系人分组
- [x] 群备注
- [x] 群分组
- [ ] 组织管理
#### 通话功能
@@ -124,9 +123,8 @@ 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微信群有效期4月9日
QQ群825505574
## 如何联系我们

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -1,5 +1,8 @@
{
"compilerOptions": {
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"paths": {
"@/*": ["./src/*"]
}

View File

@@ -1,6 +1,6 @@
{
"name": "anylink-web",
"version": "1.1.0",
"version": "1.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -23,6 +23,7 @@
"element-plus": "^2.8.0",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"pinyin-pro": "^3.26.0",
"protobufjs": "^7.4.0",
"uuid": "^10.0.0",
"vue": "^3.4.29",
@@ -34,7 +35,9 @@
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/runtime-dom": "^3.5.13",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"husky": "^8.0.0",

View File

@@ -12,6 +12,22 @@ export const msgChatPullMsgService = (obj) => {
return request.get('/chat/pullMsg', { params: obj })
}
export const msgChatHistoryService = (obj) => {
return request.get('/chat/history', { params: obj })
}
export const msgChatRevokeMsgService = (obj) => {
return request.post('/chat/revokeMsg', obj)
}
export const msgChatDeleteMsgService = (obj) => {
return request.post('/chat/deleteMsg', obj)
}
export const msgAtService = () => {
return request.get('/chat/queryAt')
}
export const msgChatCreateSessionService = (obj) => {
return request.post('/chat/createSession', obj)
}
@@ -20,6 +36,10 @@ export const msgChatQuerySessionService = (obj) => {
return request.get('/chat/querySession', { params: obj })
}
export const msgChatQueryMessagesService = (obj) => {
return request.get('/chat/queryMessages', { params: obj })
}
export const msgChatCloseSessionService = (obj) => {
return request.post('/chat/closeSession', obj)
}

View File

@@ -1,7 +1,79 @@
import request from '@/js/utils/request'
export const mtsUploadService = (obj) => {
return request.postForm('/mts/upload', obj)
export const mtsUploadServiceForImage = async (requestBody, { originFile, thumbFile }) => {
const res = await request.postForm('/mts/getUploadUrl', requestBody)
const scope = res.data.data.scope
const objectId = res.data.data.objectId
const originUrl = res.data.data.originUrl
const thumbUrl = res.data.data.thumbUrl
if (scope === 1 && originUrl && thumbUrl) {
// 如果文件之前已经上传过,直接获取下载地址
return res
} else {
const uploadOriginUrl = res.data.data.uploadOriginUrl
const uploadThumbUrl = res.data.data.uploadThumbUrl
// 2 上传原图
const originResponse = await fetch(uploadOriginUrl, {
method: 'PUT',
body: originFile,
headers: {
'Content-Type': originFile.type || 'application/octet-stream' // 设置 Content-Type
}
})
if (!originResponse.ok) {
throw new Error('原图上传失败')
}
// 3 上传缩略图,如果原图和缩略图一样,就不上传
if (uploadThumbUrl !== uploadOriginUrl) {
const thumbResponse = await fetch(uploadThumbUrl, {
method: 'PUT',
body: thumbFile,
headers: {
'Content-Type': thumbFile.type || 'application/octet-stream' // 设置 Content-Type
}
})
if (!thumbResponse.ok) {
throw new Error('缩略图上传失败')
}
}
// 4 上报服务端上传成功服务端返回预签名下载URL
const reportResponse = await request.postForm('/mts/reportUploaded', { objectId })
return reportResponse
}
}
export const mtsUploadService = async (requestBody, { originFile }) => {
// 1 获取上传的预签名URL
const res = await request.postForm('/mts/getUploadUrl', requestBody)
const scope = res.data.data.scope
const objectId = res.data.data.objectId
const downloadUrl = res.data.data.downloadUrl
if (scope === 1 && downloadUrl) {
// 如果文件之前已经上传过,直接过的下载地址
return res
} else {
const uploadUrl = res.data.data.uploadUrl
// 2 上传文件
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: originFile,
headers: {
'Content-Type': originFile.type || 'application/octet-stream' // 设置 Content-Type
}
})
if (!uploadResponse.ok) {
throw new Error('文件上传失败')
}
// 3 上报服务端上传成功服务端返回预签名下载URL
const reportResponse = await request.postForm('/mts/reportUploaded', { objectId })
return reportResponse
}
}
export const mtsImageService = (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="1746605979357" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5945" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M850.538343 895.516744c-11.494799 0-22.988574-4.386914-31.763424-13.161764L141.103692 204.669426c-17.548678-17.534352-17.548678-45.992497 0-63.525825 17.548678-17.548678 45.977147-17.548678 63.525825 0l677.671227 677.685553c17.548678 17.534352 17.548678 45.992497 0 63.525825C873.526917 891.128807 862.032118 895.516744 850.538343 895.516744z" fill="#000000" p-id="5946"></path><path d="M172.867116 895.516744c-11.494799 0-22.988574-4.386914-31.763424-13.161764-17.548678-17.534352-17.548678-45.992497 0-63.525825l677.671227-677.685553c17.548678-17.548678 45.977147-17.548678 63.525825 0 17.548678 17.534352 17.548678 45.992497 0 63.525825L204.629517 882.354979C195.85569 891.128807 184.360891 895.516744 172.867116 895.516744z" fill="#000000" p-id="5947"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
src/assets/svg/copy.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="1745740018080" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3474" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M394.666667 106.666667h448a74.666667 74.666667 0 0 1 74.666666 74.666666v448a74.666667 74.666667 0 0 1-74.666666 74.666667H394.666667a74.666667 74.666667 0 0 1-74.666667-74.666667V181.333333a74.666667 74.666667 0 0 1 74.666667-74.666666z m0 64a10.666667 10.666667 0 0 0-10.666667 10.666666v448a10.666667 10.666667 0 0 0 10.666667 10.666667h448a10.666667 10.666667 0 0 0 10.666666-10.666667V181.333333a10.666667 10.666667 0 0 0-10.666666-10.666666H394.666667z m245.333333 597.333333a32 32 0 0 1 64 0v74.666667a74.666667 74.666667 0 0 1-74.666667 74.666666H181.333333a74.666667 74.666667 0 0 1-74.666666-74.666666V394.666667a74.666667 74.666667 0 0 1 74.666666-74.666667h74.666667a32 32 0 0 1 0 64h-74.666667a10.666667 10.666667 0 0 0-10.666666 10.666667v448a10.666667 10.666667 0 0 0 10.666666 10.666666h448a10.666667 10.666667 0 0 0 10.666667-10.666666v-74.666667z" fill="#000000" p-id="3475"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 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="1745740451964" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="22293" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M781.28 851.36a58.56 58.56 0 0 1-58.56 58.56H301.28a58.72 58.72 0 0 1-58.56-58.56V230.4h538.56z m-421.6-725.92a11.84 11.84 0 0 1 12-12h281.28a11.84 11.84 0 0 1 12 12V160H359.68zM956.8 160H734.72v-34.56a81.76 81.76 0 0 0-81.76-81.76H371.68a82.08 82.08 0 0 0-81.76 81.76V160H67.2a35.36 35.36 0 0 0 0 70.56h105.12v620.8a128.96 128.96 0 0 0 128.96 128.96h421.44a128.96 128.96 0 0 0 128.96-128.96V230.4H956.8a35.2 35.2 0 0 0 35.2-35.2 34.56 34.56 0 0 0-35.2-35.2zM512 804.16a35.2 35.2 0 0 0 35.2-35.36V393.92a35.2 35.2 0 1 0-70.4 0V768.8a35.2 35.2 0 0 0 35.2 35.36m-164.32 0a35.36 35.36 0 0 0 35.36-35.36V393.92a35.36 35.36 0 1 0-70.56 0V768.8a36.32 36.32 0 0 0 35.2 35.36m328.64 0a35.36 35.36 0 0 0 35.2-35.36V393.92a35.36 35.36 0 1 0-70.56 0V768.8a35.36 35.36 0 0 0 35.36 35.36" fill="#000000" p-id="22294"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 972 B

After

Width:  |  Height:  |  Size: 972 B

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="1745740069030" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4754" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M832 250.352L468.215 612.354c-15.66 15.582-40.986 15.52-56.569-0.14-15.582-15.659-15.52-40.985 0.14-56.568L777.222 192H626c-22.091 0-40-17.909-40-40s17.909-40 40-40h174c61.856 0 112 50.144 112 112v174c0 22.091-17.909 40-40 40s-40-17.909-40-40V250.352z m0 339.909c0-22.092 17.909-40 40-40s40 17.908 40 40V800c0 61.856-50.144 112-112 112H224c-61.856 0-112-50.144-112-112V224c0-61.856 50.144-112 112-112h209.74c22.09 0 40 17.909 40 40s-17.91 40-40 40H224c-17.673 0-32 14.327-32 32v576c0 17.673 14.327 32 32 32h576c17.673 0 32-14.327 32-32V590.26z" fill="#000000" p-id="4755"></path></svg>

After

Width:  |  Height:  |  Size: 922 B

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="1746604353367" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4613" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M933.686613 826.823111c0 33.28-26.908444 60.245333-60.131555 60.245333H151.350613a60.188444 60.188444 0 0 1-60.188444-60.245333H0.879502c-9.102222 93.809778 53.930667 150.641778 120.376889 150.641778h782.392889A120.433778 120.433778 0 0 0 1024.026169 856.974222v-180.679111h-90.339556v150.528zM978.91328 0H587.688391a45.169778 45.169778 0 0 0 0 90.282667h297.244445L446.490169 529.123556a45.169778 45.169778 0 0 0 63.829333 63.886222l423.367111-423.765334v267.434667a45.169778 45.169778 0 0 0 90.225778 0V45.169778a44.942222 44.942222 0 0 0-44.942222-45.169778z" fill="#000000" p-id="4614"></path><path d="M0.026169 102.4m42.666667 0l426.666666 0q42.666667 0 42.666667 42.666667l0 0q0 42.666667-42.666667 42.666666l-426.666666 0q-42.666667 0-42.666667-42.666666l0 0q0-42.666667 42.666667-42.666667Z" fill="#000000" p-id="4615"></path><path d="M0.026169 327.395556m42.666667 0l290.133333 0q42.666667 0 42.666667 42.666666l0 0q0 42.666667-42.666667 42.666667l-290.133333 0q-42.666667 0-42.666667-42.666667l0 0q0-42.666667 42.666667-42.666666Z" fill="#000000" p-id="4616"></path><path d="M0.026169 552.334222m42.666667 0l290.133333 0q42.666667 0 42.666667 42.666667l0 0q0 42.666667-42.666667 42.666667l-290.133333 0q-42.666667 0-42.666667-42.666667l0 0q0-42.666667 42.666667-42.666667Z" fill="#000000" p-id="4617"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 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="1744875720033" class="svg-icon" viewBox="0 0 1412 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9979" xmlns:xlink="http://www.w3.org/1999/xlink" width="275.78125" height="200"><path d="M1393.516743 51.782272a71.326417 71.326417 0 0 0-49.610701-22.527848L803.837949 8.24491l-40.906759 89.140366 56.231346 167.72301-101.675454 190.815821 41.436411 153.634277 102.540552 131.794976 148.690862-135.767363c6.956091-6.355819 16.189684-9.622004 25.599828-9.268903 9.392489 0.353101 18.449531 4.466729 24.717075 11.352199l191.168921 204.392557a35.310107 35.310107 0 0 1 6.267544 38.699878 35.857414 35.857414 0 0 1-33.774117 20.479862l-523.878409-20.303312-26.129479 74.151226 28.389326 66.912653 602.213882 23.075155a71.785448 71.785448 0 0 0 51.199656-18.767322 69.790427 69.790427 0 0 0 22.810329-49.028084l33.597567-844.917905a69.084225 69.084225 0 0 0-18.802632-50.581729zM1006.517966 439.257736c-59.020845-2.242192-104.976949-51.446826-102.717103-109.779124 2.259847-58.314642 52.047098-103.741096 111.067943-101.516559 59.020845 2.259847 104.994604 51.464482 102.717103 109.779124-2.259847 58.332297-52.047098 103.741096-111.067943 101.516559z m-388.234631 487.049966l18.09643-77.752856-425.345554 28.901323A35.769139 35.769139 0 0 1 176.553891 858.0003a35.115902 35.115902 0 0 1 5.049346-38.894084l315.125053-357.691388a35.804449 35.804449 0 0 1 25.952929-12.023091 36.08693 36.08693 0 0 1 26.623821 10.822548l105.506601 108.755131-45.444108-115.976048 80.083323-200.084724-73.798124-160.184302L646.443146 0 66.351046 39.564975C26.980277 42.319164-2.591938 75.863766 0.179905 114.757849l58.844294 843.717361a69.419671 69.419671 0 0 0 24.205079 48.162987 72.562271 72.562271 0 0 0 51.711652 17.213677l517.963965-35.274797-34.65687-62.28703z" p-id="9980"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 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="1745740175552" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7413" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M727.984 393.184a31.968 31.968 0 0 0-45.248 0.256L449.44 629.248l-103.28-106.112a32 32 0 1 0-45.888 44.608l126.032 129.504c0.048 0.096 0.192 0.096 0.256 0.192 0.064 0.064 0.096 0.192 0.16 0.256 2.016 1.984 4.512 3.2 6.88 4.544 1.248 0.672 2.24 1.792 3.52 2.304a31.728 31.728 0 0 0 24.064 0.064c1.232-0.512 2.208-1.536 3.392-2.176 2.4-1.344 4.896-2.528 6.944-4.544 0.064-0.064 0.096-0.192 0.192-0.256 0.064-0.096 0.16-0.128 0.256-0.192l256.224-259.008a32 32 0 0 0-0.224-45.248zM832.992 928h-640c-52.928 0-96-43.072-96-96V192c0-52.928 43.072-96 96-96h640c52.928 0 96 43.072 96 96v640c0 52.928-43.056 96-96 96z m-640-768c-17.632 0-32 14.368-32 32v640c0 17.664 14.368 32 32 32h640a32 32 0 0 0 32-32V192c0-17.632-14.336-32-32-32h-640z" fill="#000000" p-id="7414"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
src/assets/svg/quote.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="1745740304487" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16699" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M309.44 997.376h-92.8l1.6-131.648-83.456 1.472-5.504-1.28a186.752 186.752 0 0 1-82.816-42.88l-4.48-4.608a190.4 190.4 0 0 1-41.088-84.928L0 724.224l1.024-555.2c6.464-30.848 20.608-60.032 40.768-84.352l4.48-4.672A181.824 181.824 0 0 1 129.728 36.864l9.92-1.088 756.608 1.536c29.184 7.68 56.896 22.016 80.064 41.408l4.864 4.736c21.888 24.576 36.48 54.656 42.112 87.104l0.704 8.064-0.96 554.816c-6.4 30.976-20.544 60.16-40.832 84.544l-4.544 4.736a184.256 184.256 0 0 1-83.136 43.008l-10.368 1.088-385.152-1.792-189.568 132.352z m3.072-226.432l-1.344 111.68 158.72-110.848 408.96 1.92a92.16 92.16 0 0 0 33.92-17.92c8.576-10.944 14.784-23.68 18.176-37.12V183.104a83.712 83.712 0 0 0-17.088-35.2 115.648 115.648 0 0 0-35.968-19.008H145.216a88.832 88.832 0 0 0-33.856 17.792 101.952 101.952 0 0 0-18.24 37.184v535.168c3.328 13.376 9.536 25.984 17.984 36.8 9.92 8.32 21.504 14.4 33.984 17.984l167.424-2.88z" fill="#000000" p-id="16700" data-spm-anchor-id="a313x.search_index.0.i23.4a633a81AeDDIC" class=""></path><path d="M353.088 262.4h70.4v66.432s-106.368 59.2-105.92 132.416H423.68v165.504H211.84V428.16A296.768 296.768 0 0 1 353.088 262.4zM677.44 262.4h70.4v66.432s-106.368 59.2-105.92 132.416h105.92v165.504H536.256V428.16a296.832 296.832 0 0 1 141.184-165.76z" fill="#000000" p-id="16701" data-spm-anchor-id="a313x.search_index.0.i24.4a633a81AeDDIC" class="selected"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 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="1745741368623" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="23509" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M237.303467 377.216l113.152 106.026667c16.584533 16.763733 33.6512 43.933867 17.066666 60.693333-16.597333 16.759467-39.227733 16.759467-55.816533 0L138.368 368.3968c-13.162667-13.2608-14.122667-34.491733-0.96-47.752533l174.301867-178.2784c16.5888-16.759467 39.223467-16.759467 55.812266 0s-0.477867 43.933867-17.066666 60.689066L238.775467 313.216h380.881066c153.211733 0 276.343467 132.881067 276.343467 285.738667 0 152.853333-123.136 298.845867-276.343467 298.845866H213.457067c-23.317333 0-42.88-10.824533-42.88-34.133333 0-23.313067 19.562667-29.870933 42.88-29.870933h402.816c102.762667 0 215.714133-132.322133 215.714133-234.845867s-112.951467-221.725867-215.714133-221.725867H237.303467z" fill="#000" p-id="23510"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 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="1745812557561" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3575" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M160 0h512l256 256v704c0 35.3472-28.6528 64-64 64H160c-35.3472 0-64-28.6528-64-64V64c0-35.3472 28.6528-64 64-64z" fill="#409eff" p-id="3576" data-spm-anchor-id="a313x.search_index.0.i4.41e13a81lwq0Kw" class=""></path><path d="M702.2976 579.2896l-298.5664 177.984c-19.9488 12.0192-45.3312-2.4128-45.3312-25.856v-355.968c0-22.848 25.3824-37.2736 45.3312-25.856l298.56 177.984c19.3408 12.032 19.3408 40.288 0 51.712z" fill="#FFFFFF" p-id="3577" data-spm-anchor-id="a313x.search_index.0.i3.41e13a81lwq0Kw" class=""></path><path d="M672 0l256 256h-192c-35.3472 0-64-28.6528-64-64V0z" fill="#f5f5f5" p-id="3578" data-spm-anchor-id="a313x.search_index.0.i0.41e13a81lwq0Kw" class=""></path></svg>

After

Width:  |  Height:  |  Size: 1.0 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="1744876866180" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13144" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M876.416 272.137H707.763L840.277 91.69c18.065-24.047 12.05-60.135-12.049-81.186C804.13-7.552 768-1.545 746.914 22.519L566.212 269.133h-72.286l-271.06-168.431c-27.103-15.027-63.241-9.02-78.303 18.048-12.049 18.048-12.049 33.084-9.037 48.12 3.013 15.035 12.05 27.076 24.09 36.087l114.44 69.171H147.585C66.261 272.137 0 338.304 0 419.498v457.114C0 957.815 66.261 1024 147.584 1024h728.832C957.73 1024 1024 957.815 1024 876.62V419.5c0-81.195-66.27-147.362-147.584-147.362zM150.579 572.894l-3.004-15.044c-3.02-21.06 9.037-42.112 30.115-45.133l162.636-30.063c21.078-3.004 42.155 9.037 45.167 30.063l3.021 15.053c3.012 21.051-9.037 42.103-30.114 45.124l-162.637 30.054c-21.077 3.021-42.163-9.002-45.184-30.054z m472.858 228.548c-15.07 9.028-30.114 15.053-54.221 15.053-18.065 0-36.13-9.028-51.183-27.076-3.02-3.021-6.033-3.021-9.045 0-36.139 36.087-93.372 33.083-126.498-6.016-15.053-21.06-24.09-42.104-24.09-69.163 0-15.053 12.05-24.073 24.09-24.073 12.049 0 24.09 9.029 24.09 24.073 0 12.023 3.02 27.06 12.048 36.079 12.05 18.03 33.135 21.052 48.188 6.024 12.04-9.028 18.074-21.051 18.074-36.096v-6.007c0-27.076 51.2-27.076 51.2 0 0 12.023 3.003 24.047 12.032 36.079 18.082 21.052 42.163 21.052 57.233-3.004 6.024-9.02 9.045-21.052 9.045-33.075 0-12.04 9.037-21.077 18.065-24.073s21.086 3.004 27.102 12.032c3.03 3.004 3.03 6.016 3.03 12.041 3.003 36.079-9.046 66.15-39.16 87.202zM876.416 557.85l-3.02 15.044c-3.005 21.052-24.082 36.079-45.168 30.054l-159.616-30.054c-21.086-3.02-36.147-24.073-30.114-45.124l3.004-15.053c3.02-21.035 24.098-36.088 45.184-30.063l159.616 30.063c21.077 3.02 33.135 24.072 30.114 45.133z m0 0" p-id="13145"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
import { Search, ArrowLeft, ArrowRight, Edit, Check } from '@element-plus/icons-vue'
import { Search, ArrowLeft, ArrowRight, Edit } from '@element-plus/icons-vue'
import { el_loading_options, PARTITION_TYPE } from '@/const/commonConst'
import GroupItem from '@/components/item/GroupItem.vue'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
@@ -9,7 +9,7 @@ import GroupAvatarIcon from '@/components/common/GroupAvatarIcon.vue'
import AddButton from '@/components/common/AddButton.vue'
import DeleteButton from '@/components/common/DeleteButton.vue'
import EditAvatar from '@/components/common/EditAvatar.vue'
import { combineId } from '@/js/utils/common'
import { combineId, smartMatch } from '@/js/utils/common'
import { userQueryService } from '@/api/user'
import {
useGroupStore,
@@ -18,8 +18,8 @@ import {
useUserCardStore,
useGroupCardStore
} from '@/stores'
import SelectUserDialog from '../common/SelectUserDialog.vue'
import SingleSelectDialog from '../common/SingleSelectDialog.vue'
import SelectUserDialog from '@/components/common/SelectUserDialog.vue'
import SelectUserSingleDialog from '@/components/common/SelectUserSingleDialog.vue'
import {
groupAddMembersService,
groupDelMembersService,
@@ -40,7 +40,7 @@ const messageData = useMessageStore()
const userCardData = useUserCardStore()
const groupCardData = useGroupCardStore()
const isShowSelectDialog = ref(false)
const isShowSingleSelectDialog = ref(false)
const isShowSelectUserSingleDialog = ref(false)
const isShowEditAvatar = ref(false)
const myAccount = computed(() => userData.user.account)
const newGroupName = ref('')
@@ -161,7 +161,7 @@ const validMembersSorted = computed(() => {
data.push(item)
} else {
if (
item.nickName.toLowerCase().includes(memberSearchKey.value.toLowerCase()) ||
smartMatch(item.nickName, memberSearchKey.value) ||
item.account === memberSearchKey.value
) {
data.push(item)
@@ -661,7 +661,7 @@ const onConfirmSingleSelect = (selected) => {
}
})
.finally(() => {
isShowSingleSelectDialog.value = false
isShowSelectUserSingleDialog.value = false
loadingInstance.close()
})
}
@@ -689,7 +689,7 @@ const onChangePartition = () => {
:modelValue="groupCardData.isShow"
:direction="'rtl'"
:size="385"
:z-index="1"
:z-index="1000"
modal-class="group-card-modal"
:show-close="false"
@close="groupCardData.setClosed()"
@@ -878,14 +878,14 @@ const onChangePartition = () => {
>
<span
style="font-size: 14px; cursor: pointer"
@click="isShowSingleSelectDialog = true"
@click="isShowSelectUserSingleDialog = true"
>转移群主</span
>
<el-button
:icon="ArrowRight"
size="small"
circle
@click="isShowSingleSelectDialog = true"
@click="isShowSelectUserSingleDialog = true"
/>
</div>
<div
@@ -977,7 +977,11 @@ const onChangePartition = () => {
<div
style="width: 240px; display: flex; align-items: center; justify-content: space-between"
>
<el-select v-model="newPartitionId" placeholder="请选择分组" style="width: 200px">
<el-select
v-model="newPartitionId"
placeholder="请选择分组"
@change="onChangePartition()"
>
<el-option
v-for="item in Object.values(partitions)"
:key="item.partitionId"
@@ -985,14 +989,6 @@ const onChangePartition = () => {
:value="item.partitionId"
/>
</el-select>
<el-button
type="success"
:icon="Check"
size="small"
title="确认"
circle
@click="onChangePartition()"
></el-button>
</div>
</div>
</div>
@@ -1041,8 +1037,8 @@ const onChangePartition = () => {
</div>
</template>
</SelectUserDialog>
<SingleSelectDialog
v-model="isShowSingleSelectDialog"
<SelectUserSingleDialog
v-model="isShowSelectUserSingleDialog"
:options="validMembersSorted"
:disabledOptionIds="new Array(myAccount)"
@showUserCard="onShowUserCard"
@@ -1051,7 +1047,7 @@ const onChangePartition = () => {
<template #title>
<div style="font-size: 16px; font-weight: bold; white-space: nowrap">转移群主</div>
</template>
</SingleSelectDialog>
</SelectUserSingleDialog>
<EditAvatar
v-model="isShowEditAvatar"
:model="'group'"

View File

@@ -157,10 +157,6 @@ watch(
:show-close="false"
@close="onClose"
>
<template #header>
<div style="background-color: red"></div>
</template>
<div class="user-card" @click.self="preventClose($event)">
<div class="header">
<el-icon class="close-button" @click="onClose"><Close /></el-icon>

View File

@@ -1,9 +1,10 @@
<script setup>
import { ref, computed } from 'vue'
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'
import { Plus, Check, RefreshLeft, RefreshRight, Refresh } from '@element-plus/icons-vue'
import { mtsUploadServiceForImage } from '@/api/mts'
import { getMd5 } from '@/js/utils/file'
import { prehandleImage } from '@/js/utils/image'
import 'vue-cropper/dist/index.css'
import { VueCropper } from 'vue-cropper'
@@ -17,6 +18,7 @@ const previewImg = ref('')
const isLoading = ref(false)
const fileName = ref('')
const resetData = ref({})
const currentBlobUrl = ref('') // 新增变量,用于存储当前的 Blob URL
const avatar = computed(() => {
if (props.model === 'user') {
@@ -41,19 +43,31 @@ const onOpen = () => {
// 关闭的时候触发
const onClose = () => {
isLoading.value = false
// 释放当前的 Blob URL
if (currentBlobUrl.value) {
URL.revokeObjectURL(currentBlobUrl.value)
currentBlobUrl.value = ''
}
}
// 选择了文件触发
const onSelected = (file) => {
// 释放之前的 Blob URL
if (currentBlobUrl.value) {
URL.revokeObjectURL(currentBlobUrl.value)
currentBlobUrl.value = ''
}
fileName.value = file.name
srcImg.value = URL.createObjectURL(file.raw)
previewImg.value = srcImg.value
currentBlobUrl.value = srcImg.value // 存储当前的 Blob URL
resetData.value = {
previewImg: previewImg.value
}
}
const onUpload = async () => {
const onSave = async () => {
cropper.value.getCropBlob(async (blob) => {
const lastDotIndex = fileName.value.lastIndexOf('.')
const prefix = fileName.value.substring(0, lastDotIndex)
@@ -69,14 +83,30 @@ const onUpload = async () => {
isLoading.value = true
try {
const res = await mtsUploadService({ file: file, storeType: 0 })
const md5 = await getMd5(file)
const prehandleImageObj = await prehandleImage(file)
const files = {
originFile: file,
thumbFile: prehandleImageObj.thumbFile
}
const requestBody = {
storeType: 0,
md5,
fileName: file.name,
fileRawType: file.type,
size: file.size,
originWidth: prehandleImageObj.originWidth,
originHeight: prehandleImageObj.originHeight,
thumbWidth: prehandleImageObj.thumbWidth,
thumbHeight: prehandleImageObj.thumbHeight
}
const res = await mtsUploadServiceForImage(requestBody, files)
emit('update:newAvatar', {
avatarId: res.data.data.objectId,
avatar: res.data.data.originUrl,
avatarThumb: res.data.data.thumbUrl
})
emit('update:modelValue', false)
ElMessage.success('头像上传成功')
emit('update:modelValue', false) //关闭窗口
} catch (error) {
/* empty */
} finally {
@@ -168,6 +198,7 @@ const onRotateRight = () => {
:auto-upload="false"
:show-file-list="false"
:on-change="onSelected"
accept="image/*"
style="display: flex"
>
<template #trigger>
@@ -175,13 +206,13 @@ const onRotateRight = () => {
</template>
<el-button
type="success"
:icon="Upload"
:icon="Check"
size="large"
@click="onUpload"
@click="onSave"
:loading="isLoading"
style="margin-left: 10px"
>
上传头像
保存头像
</el-button>
</el-upload>
</div>

View File

@@ -28,7 +28,7 @@ watch([() => props.isShow, () => props.defaultInput], ([newIsShow, newDefaultInp
:modal="false"
:top="'40vh'"
:width="'360px'"
:z-index="1"
:z-index="1000"
style="border-radius: 10px"
@close="onClose"
>

View File

@@ -17,6 +17,8 @@ const avatarSize = computed(() => {
return 50
case 'small':
return 30
case 'tiny':
return 24
case 'default':
default:
return 40
@@ -31,6 +33,8 @@ const svgSize = computed(() => {
return 30
case 'small':
return 18
case 'tiny':
return 16
case 'default':
default:
return 24

View File

@@ -11,9 +11,9 @@ import {
groupOwnerTransferService
} from '@/api/group'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import { combineId } from '@/js/utils/common'
import { combineId, smartMatch } from '@/js/utils/common'
import { userQueryService } from '@/api/user'
import MemberMenu from '@/views/message/components/MemberMenu.vue'
import MenuMember from '@/views/message/components/MenuMember.vue'
import { MsgType } from '@/proto/msg'
const props = defineProps(['groupId', 'memberSearchKey'])
@@ -50,7 +50,7 @@ const validMembersSorted = computed(() => {
data.push(item)
} else {
if (
item.nickName.toLowerCase().includes(props.memberSearchKey.toLowerCase()) ||
smartMatch(item.nickName, props.memberSearchKey) ||
item.account === props.memberSearchKey
) {
data.push(item)
@@ -400,7 +400,7 @@ const onSelectMenu = (item) => {
</script>
<template>
<MemberMenu :groupId="props.groupId" :account="showMenuAccount" @selectMenu="onSelectMenu">
<MenuMember :groupId="props.groupId" :account="showMenuAccount" @selectMenu="onSelectMenu">
<el-table
class="group-members-table"
:data="validMembersSorted"
@@ -491,7 +491,7 @@ const onSelectMenu = (item) => {
</template>
</el-table-column>
</el-table>
</MemberMenu>
</MenuMember>
</template>
<style lang="scss" scoped>

View File

@@ -4,6 +4,7 @@ import { Search, Close } from '@element-plus/icons-vue'
import HashNoData from '@/components/common/HasNoData.vue'
import { groupSearchGroupInfoService } from '@/api/group'
import GroupItem from '../item/GroupItem.vue'
import { smartMatch } from '@/js/utils/common'
/**
* disabledOptions: 排除项的群ID比如已经选过了某些群那么这么群组应该在待选项里被禁用
@@ -43,10 +44,7 @@ const optionKeys = computed(() => {
const data = []
Object.keys(optionsAll.value).forEach((key) => {
const item = optionsAll.value[key]
if (
item.groupId === searchKey.value ||
item.groupName.toLowerCase().includes(searchKey.value.toLowerCase())
) {
if (item.groupId === searchKey.value || smartMatch(item.groupName, searchKey.value)) {
data.push(key)
}
})
@@ -120,7 +118,7 @@ const onRemoveSelectedItem = (index) => {
:modal="false"
:top="'30vh'"
:width="'610px'"
:z-index="1"
:z-index="1000"
style="border-radius: 10px"
@open="onOpen"
@close="onClose"

View File

@@ -0,0 +1,305 @@
<script setup>
import { ref, computed } from 'vue'
import { Search, Close } from '@element-plus/icons-vue'
import SessionTitleItem from '@/components/item/SessionTitleItem.vue'
import HashNoData from '@/components/common/HasNoData.vue'
import { userQueryService, userQueryByNickService } from '@/api/user'
import { combineId, smartMatch } from '@/js/utils/common'
import { useUserStore, useMessageStore } from '@/stores'
import { MsgType } from '@/proto/msg'
import { ElMessage } from 'element-plus'
const props = defineProps(['isShow', 'sessionListSortedKey'])
const emit = defineEmits(['update:isShow', 'showUserCard', 'showGroupCard', 'confirm', 'close'])
const userData = useUserStore()
const messageData = useMessageStore()
const selected = ref([])
const myAccount = computed(() => {
return userData.user.account
})
const searchKey = ref('')
const optionsFromServer = ref({})
const optionsAll = computed(() => {
return {
...messageData.sessionList,
...optionsFromServer.value
}
})
const optionKeys = computed(() => {
const allKeys = [...props.sessionListSortedKey, ...Object.keys(optionsFromServer.value)]
if (!searchKey.value) {
return allKeys
} else {
const data = []
allKeys.forEach((key) => {
const item = optionsAll.value[key]
if (
item.sessionType === MsgType.CHAT &&
(item.objectInfo.account === searchKey.value ||
smartMatch(item.objectInfo.nickName, searchKey.value) ||
smartMatch(item.mark, searchKey.value))
) {
data.push(key)
} else if (
item.sessionType === MsgType.GROUP_CHAT &&
(item.objectInfo.groupId === searchKey.value ||
smartMatch(item.objectInfo.groupName, searchKey.value) ||
smartMatch(item.mark, searchKey.value))
) {
data.push(key)
}
})
return data
}
})
let timer
const onQuery = () => {
if (!searchKey.value) return
clearTimeout(timer)
const key = searchKey.value //在异步执行中,变量禁止使用响应式,因为在将来执行的时候响应式数据随时会发生改变
timer = setTimeout(async () => {
userQueryByNickService({ keyWords: key }).then((res) => {
res.data.data?.forEach((item) => {
const sessionId = combineId(myAccount.value, item.account)
if (!messageData.sessionList[sessionId]) {
// 这里先不create Session点击确认转发才create Session
optionsFromServer.value[sessionId] = {}
optionsFromServer.value[sessionId].sessionId = sessionId
optionsFromServer.value[sessionId].sessionType = MsgType.CHAT
optionsFromServer.value[sessionId].mark = ''
optionsFromServer.value[sessionId].objectInfo = item
}
})
})
const sessionId = combineId(myAccount.value, key)
if (!messageData.sessionList[sessionId]) {
userQueryService({ account: key }).then((res) => {
if (res.data.data) {
// 这里先不create Session点击确认转发才create Session
optionsFromServer.value[sessionId] = {}
optionsFromServer.value[sessionId].sessionId = sessionId
optionsFromServer.value[sessionId].sessionType = MsgType.CHAT
optionsFromServer.value[sessionId].mark = ''
optionsFromServer.value[sessionId].objectInfo = res.data.data
}
})
}
}, 300)
}
const onShowUserCard = (account) => {
emit('showUserCard', { account })
}
const onShowGroupCard = (groupId) => {
emit('showGroupCard', { groupId })
}
const onConfirm = () => {
if (selected.value.length === 0) {
ElMessage.warning('您还没有选择目标会话')
} else {
const data = []
selected.value.forEach((account) => {
data.push(optionsAll.value[account])
})
emit('confirm', data)
}
}
const onOpen = () => {
searchKey.value = ''
}
const onClose = () => {
selected.value = []
optionsFromServer.value = {}
emit('update:isShow', false)
emit('close')
}
const onCancle = () => {
emit('update:isShow', false)
emit('close')
}
const onClearSelected = () => {
selected.value = []
}
const onRemoveSelectedItem = (index) => {
selected.value.splice(index, 1)
}
</script>
<template>
<el-dialog
class="select-dialog"
:model-value="props.isShow"
:modal="false"
:top="'30vh'"
:width="'610px'"
:z-index="1000"
style="border-radius: 10px"
@open="onOpen"
@close="onClose"
>
<template #header>
<slot name="title"></slot>
</template>
<div class="main bdr-t bdr-b bdr-l bdr-r">
<div class="left bdr-r">
<el-input
v-model.trim="searchKey"
placeholder="搜索:昵称/账号/备注/群名称"
:prefix-icon="Search"
:clearable="true"
@input="onQuery"
/>
<div v-if="optionKeys.length > 0" class="my-scrollbar" style="flex: 1; overflow-y: scroll">
<el-checkbox-group v-model="selected">
<el-checkbox v-for="item in optionKeys" :key="item" :value="item">
<SessionTitleItem
:session="optionsAll[item]"
:keyWords="searchKey"
@showUserCard="onShowUserCard"
@showGroupCard="onShowGroupCard"
></SessionTitleItem>
</el-checkbox>
</el-checkbox-group>
</div>
<HashNoData v-else></HashNoData>
</div>
<div class="right">
<div class="head bdr-b">
<div style="font-size: 13px; color: gray">
{{ `已选择:${selected.length} 个会话` }}
</div>
<el-button type="info" size="small" @click="onClearSelected" plain>清空</el-button>
</div>
<div v-if="selected.length > 0" class="my-scrollbar" style="flex: 1; overflow-y: scroll">
<div class="selected-item" v-for="(item, index) in selected" :key="index">
<SessionTitleItem
:session="optionsAll[item]"
@showUserCard="onShowUserCard"
@showGroupCard="onShowGroupCard"
></SessionTitleItem>
<el-button :icon="Close" size="small" circle @click="onRemoveSelectedItem(index)" />
</div>
</div>
<HashNoData v-else></HashNoData>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="info" @click="onCancle" plain>取消</el-button>
<el-button type="primary" @click="onConfirm" plain>确认</el-button>
</div>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.main {
height: 360px;
margin: 10px 0 10px 0;
display: flex;
flex-direction: row;
.left {
width: 49%;
padding: 10px;
display: flex;
flex-direction: column;
overflow: hidden;
.head {
display: flex;
align-items: center;
}
.el-checkbox-group {
display: flex;
flex-direction: column;
.el-checkbox {
height: 45px;
margin: 0 2px 2px 0;
padding: 0 10px 0 10px;
border-radius: 8px;
color: black;
&:hover {
background-color: #dedfe0;
}
}
.is-checked {
background-color: #dedfe0;
}
}
}
.right {
padding: 10px;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.head {
height: 30px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.selected-item {
height: 45px;
margin: 0 0 2px 0;
padding: 0 10px 0 10px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
color: black;
--close-button-color: transparent;
&:hover {
background: #dedfe0;
--close-button-color: auto;
}
.el-button {
border: none;
color: var(--close-button-color);
background-color: var(--close-button-background-color);
&:hover {
--close-button-background-color: #f0f0f0;
}
}
}
}
}
.el-input {
width: 100%;
height: 30px;
margin-bottom: 10px;
:deep(.el-input__wrapper) {
border-radius: 25px;
}
}
</style>

View File

@@ -4,6 +4,7 @@ import { Search, Close } from '@element-plus/icons-vue'
import ContactItem from '@/components/item/ContactItem.vue'
import HashNoData from '@/components/common/HasNoData.vue'
import { userQueryService, userQueryByNickService } from '@/api/user'
import { smartMatch } from '@/js/utils/common'
/**
* disabledOptions: 排除项的账号数组,比如已经选过了某些用户,那么这么用户应该在待选项里被禁用
@@ -43,10 +44,7 @@ const optionKeys = computed(() => {
const data = []
Object.keys(optionsAll.value).forEach((key) => {
const item = optionsAll.value[key]
if (
item.account === searchKey.value ||
item.nickName.toLowerCase().includes(searchKey.value.toLowerCase())
) {
if (item.account === searchKey.value || smartMatch(item.nickName, searchKey.value)) {
data.push(key)
}
})
@@ -123,7 +121,7 @@ const onRemoveSelectedItem = (index) => {
:modal="false"
:top="'30vh'"
:width="'610px'"
:z-index="1"
:z-index="1000"
style="border-radius: 10px"
@open="onOpen"
@close="onClose"

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import { smartMatch } from '@/js/utils/common'
const props = defineProps(['modelValue', 'options', 'disabledOptionIds'])
const emit = defineEmits(['update:modelValue', 'showUserCard', 'confirm'])
@@ -16,10 +17,7 @@ const showOptions = computed(() => {
} else {
const data = []
props.options.forEach((item) => {
if (
item.account === searchKey.value ||
item.nickName.toLowerCase().includes(searchKey.value.toLowerCase())
) {
if (item.account === searchKey.value || smartMatch(item.nickName, searchKey.value)) {
data.push(item)
}
})
@@ -71,7 +69,7 @@ const onConfirm = () => {
:modal="false"
:top="'30vh'"
:width="'300px'"
:z-index="1"
:z-index="1000"
style="height: 460px; border-radius: 10px"
@open="onOpen"
@close="onClose"

View File

@@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { getAvatarColor, getFontColor } from '@/js/utils/common'
import { STATUS } from '@/const/userConst'
import default_avatar from '@/assets/image/default_avatar.png'
import { ElAvatar } from 'element-plus'
const props = defineProps(['showName', 'showId', 'showAvatarThumb', 'userStatus', 'size'])
@@ -12,12 +13,28 @@ const avatarSize = computed(() => {
return 50
case 'small':
return 30
case 'tiny':
return 24
case 'default':
default:
return 40
}
})
const avatarFontSize = computed(() => {
switch (props.size) {
case 'large':
return 20
case 'small':
return 16
case 'tiny':
return 14
case 'default':
default:
return 18
}
})
const statusCircleSize = computed(() => {
switch (props.size) {
case 'large':
@@ -87,7 +104,7 @@ watch(
<span
class="first-char-box"
v-else-if="firstChar"
:style="{ backgroundColor: avatarColor, color: fontColor }"
:style="{ backgroundColor: avatarColor, color: fontColor, fontSize: avatarFontSize + 'px' }"
>
{{ firstChar }}
</span>
@@ -112,7 +129,6 @@ watch(
position: relative;
.first-char-box {
font-size: 18px;
width: 100%;
height: 100%;
border-radius: 50%;

View File

@@ -0,0 +1,141 @@
<script setup>
import { computed } from 'vue'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import GroupAvatarIcon from '@/components/common/GroupAvatarIcon.vue'
import { MsgType } from '@/proto/msg'
import { useGroupStore } from '@/stores'
import { highLightedText } from '@/js/utils/common'
/**
* objectInfo对象详情
* keyWords搜索关键字用于高亮显示检索的关键字
*/
const props = defineProps(['session', 'keyWords'])
const emit = defineEmits(['showUserCard', 'showGroupCard'])
const groupData = useGroupStore()
const showName = computed(() => {
let name = ''
if (props.session.sessionType === MsgType.CHAT) {
name = props.session.objectInfo.nickName
} else if (props.session.sessionType === MsgType.GROUP_CHAT) {
name = props.session.objectInfo.groupName
} else {
return ''
}
return props.session.mark ? `${props.session.mark}(${name})` : name
})
const showId = computed(() => {
if (props.session.sessionType === MsgType.CHAT) {
return props.session.objectInfo.account
} else if (props.session.sessionType === MsgType.GROUP_CHAT) {
return props.session.objectInfo.groupId
} else {
return ''
}
})
const onShowUserCard = (e) => {
e.preventDefault()
emit('showUserCard', showId.value)
}
const onShowGroupCard = (e) => {
e.preventDefault()
emit('showGroupCard', showId.value)
}
</script>
<template>
<div class="session-wrapper">
<div v-if="props.session.sessionType === MsgType.CHAT" class="user-session">
<UserAvatarIcon
class="user-session-avatar"
:showName="showName"
:showId="showId"
:showAvatarThumb="props.session.objectInfo.avatarThumb"
:size="'small'"
@click="onShowUserCard"
></UserAvatarIcon>
<div class="user-session-info">
<span
class="name text-ellipsis"
:title="showName"
v-html="highLightedText(showName, props.keyWords, '#409eff')"
>
</span>
<span
class="id"
:title="showId"
v-html="highLightedText(showId, props.keyWords, '#409eff', 'full')"
>
</span>
</div>
</div>
<div v-else-if="props.session.sessionType === MsgType.GROUP_CHAT" class="group-session">
<GroupAvatarIcon
class="group-session-avatar"
:avatarThumb="groupData.groupInfoList[props.session.objectInfo.groupId].avatarThumb"
:size="'small'"
@click="onShowGroupCard"
></GroupAvatarIcon>
<div class="group-session-info">
<span
class="name text-ellipsis"
:title="showName"
v-html="highLightedText(showName, props.keyWords, '#409eff')"
>
</span>
<span
class="id"
:title="showId"
v-html="highLightedText(showId, props.keyWords, '#409eff', 'full')"
>
</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.session-wrapper {
padding: 2px 0 2px 5px;
.user-session {
display: flex;
gap: 5px;
.user-session-info {
max-width: 165px;
display: flex;
align-items: center;
gap: 5px;
user-select: text;
.id {
font-size: 12px;
}
}
}
.group-session {
display: flex;
gap: 5px;
.group-session-info {
max-width: 165px;
display: flex;
flex-direction: column;
gap: 4px;
user-select: text;
.id {
font-size: 12px;
}
}
}
}
</style>

View File

@@ -114,7 +114,7 @@ watch(searchTab, () => {
v-model="isShowSearchDialog"
:show-close="false"
:modal="false"
:z-index="1"
:z-index="1000"
@open="onOpen"
>
<template #header>

View File

@@ -11,21 +11,50 @@ export const proto = {
// 和服务端约定好的第一个消息都是从10001开始的
export const BEGIN_MSG_ID = 10001
// 消息内容类型
/**
* 消息内容类型
* MIX类型为TEXT,EMOJI,SCREENSHOT,AT,QUOTE的组合
*/
export const msgContentType = {
MIX: 0, // 组合,包含多种类型
TEXT: 1, // 文本
IMAGE: 2, // 图
RECORDING: 3, // 语音
AUDIO: 4, // 音频文件
EMOJI: 5, // 视频
VIDEO: 6, // 表情
DOCUMENT: 7 // 文档
TEXT: 0b0000000000000001, // 文本
EMOJI: 0b0000000000000010, // 表情
SCREENSHOT: 0b0000000000000100, //
AT: 0b0000000000001000, // @
QUOTE: 0b0000000000010000, // 引用
IMAGE: 0b0000001000000000, // 图片
RECORDING: 0b0000010000000000, // 语音
AUDIO: 0b0000100000000000, // 音频文件
VIDEO: 0b0001000000000000, // 视频
DOCUMENT: 0b0010000000000000, // 文档
FORWARD: 0b0100000000000000 // 合并转发消息
}
// 消息发送状态
export const msgSendStatus = {
PENDING: 'pending', // 发送中
OK: 'ok', // 发送成功
FAILED: 'failed' // 发送失败
FAILED: 'failed', // 发送失败
UPLOAD_FAILED: 'uploadFailed' // 文件上传失败
}
/**
* 消息中文件的上传状态
* 目前没有实现上传状态以及上传进度的效果
*/
export const msgFileUploadStatus = {
UPLOAD_DEFAULT: 0, // 默认状态,不上传
UPLOADING: 1, // 上传中
UPLOAD_SUCCESS: 2, // 上传成功
UPLOAD_FAILED: 3 // 上传失败
}
/**
* 消息撤回时间限制 10分钟
*/
export const MSG_REVOKE_TIME_LIMIT = 365 * 24 * 60 * 60 * 1000
/**
* 消息撤回后能重新编辑的时间限制 2分钟
*/
export const MSG_REEDIT_TIME_LIMIT = 2 * 60 * 1000

4
src/const/mtsConst.js Normal file
View File

@@ -0,0 +1,4 @@
/**
* 缩略图最大大小
*/
export const THUMB_IMAGE_MAX = 100 * 1024

View File

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

View File

@@ -4,3 +4,6 @@ export * from './receiveStatusRes'
export * from './receiveGroupChatMsg'
export * from './receiveGroupChatReadMsg'
export * from './receiveGroupSystemMsg'
export * from './receiveAtMsg'
export * from './receiveRevokeMsg'
export * from './receiveDeleteMsg'

View File

@@ -0,0 +1,18 @@
import { useMessageStore } from '@/stores'
import { jsonParseSafe } from '../utils/common'
export const onReceiveAtMsg = () => {
return (msg) => {
const messageData = useMessageStore()
const sessionId = msg.body.sessionId
const content = jsonParseSafe(msg.body.content)
messageData.addAtRecords(sessionId, {
msgId: msg.body.msgId,
sessionId,
fromId: msg.body.fromId,
groupId: msg.body.groupId,
referMsgId: content.referMsgId,
msgTime: new Date()
})
}
}

View File

@@ -30,22 +30,32 @@ export const onReceiveChatMsg = (updateScroll, capacity) => {
}
: {}
// 对方发的消息把remoteRead更新到最新
const remoteReadParams =
msg.body.fromId !== msg.body.toId
? {
remoteRead: msg.body.msgId
}
: {}
messageData.updateSession({
sessionId: sessionId,
unreadCount: messageData.sessionList[sessionId].unreadCount + 1,
...readParams
...readParams,
...remoteReadParams
})
await messageData.addMsgRecords(sessionId, [
{
sessionId: sessionId,
msgId: msg.body.msgId,
fromId: msg.body.fromId,
msgType: MsgType.CHAT,
content: msg.body.content,
msgTime: now
}
])
const showMsg = {
sessionId: sessionId,
msgId: msg.body.msgId,
fromId: msg.body.fromId,
msgType: MsgType.CHAT,
content: msg.body.content,
msgTime: now
}
await messageData.preloadResource([showMsg])
messageData.addMsgRecords(sessionId, [showMsg])
messageData.updateMsgKeySort(sessionId)
if (!messageData.sessionList[sessionId].dnd) {
playMsgReceive()

View File

@@ -0,0 +1,13 @@
import { useMessageStore } from '@/stores'
export const onReceiveDeleteMsg = () => {
return (msg) => {
const messageData = useMessageStore()
const sessionId = msg.body.sessionId
const deleteMsgIds = msg.body.content
deleteMsgIds.split(',').forEach((item) => {
messageData.removeMsgRecord(sessionId, item)
})
}
}

View File

@@ -34,16 +34,17 @@ export const onReceiveGroupChatMsg = (updateScroll, capacity) => {
...readParams
})
await messageData.addMsgRecords(sessionId, [
{
sessionId: sessionId,
msgId: msg.body.msgId,
fromId: msg.body.fromId,
msgType: MsgType.GROUP_CHAT,
content: msg.body.content,
msgTime: now
}
])
const showMsg = {
sessionId: sessionId,
msgId: msg.body.msgId,
fromId: msg.body.fromId,
msgType: MsgType.GROUP_CHAT,
content: msg.body.content,
msgTime: now
}
await messageData.preloadResource([showMsg])
messageData.addMsgRecords(sessionId, [showMsg])
messageData.updateMsgKeySort(sessionId)
if (!messageData.sessionList[sessionId].dnd) {
playMsgReceive()

View File

@@ -26,17 +26,16 @@ export const onReceiveGroupSystemMsg = (updateScroll, capacity) => {
})
})
// 更新聊天记录
await messageData.addMsgRecords(sessionId, [
{
sessionId: sessionId,
msgId: msg.body.msgId,
fromId: msg.body.fromId,
msgType: msg.header.msgType,
content: msg.body.content,
msgTime: now
}
])
const showMsg = {
sessionId: sessionId,
msgId: msg.body.msgId,
fromId: msg.body.fromId,
msgType: msg.header.msgType,
content: msg.body.content,
msgTime: now
}
messageData.addMsgRecords(sessionId, [showMsg])
messageData.updateMsgKeySort(sessionId)
// 如果是当前正打开的会话
if (messageData.selectedSessionId === sessionId) {

View File

@@ -0,0 +1,10 @@
import { useMessageStore } from '@/stores'
export const onReceiveRevokeMsg = () => {
return (msg) => {
const messageData = useMessageStore()
const sessionId = msg.body.sessionId
const revokeMsgId = msg.body.content
messageData.revokeMsgRcord(sessionId, revokeMsgId)
}
}

View File

@@ -3,22 +3,31 @@ import msgSend from '@/assets/audio/msgsend.mp3'
import { useUserStore } from '@/stores'
const userData = useUserStore()
let playMsgReceiveTimer
export const playMsgReceive = () => {
if (!userData.user.newMsgTips) {
return
}
const audio = new Audio(msgReceive)
audio.play().catch(() => {
// do nothing
})
clearTimeout(playMsgReceiveTimer)
playMsgReceiveTimer = setTimeout(() => {
const audio = new Audio(msgReceive)
audio.play().catch(() => {
// do nothing
})
}, 300)
}
let playMsgSendTimer
export const playMsgSend = () => {
if (!userData.user.sendMsgTips) {
return
}
const audio = new Audio(msgSend)
audio.play().catch(() => {
// do nothing
})
clearTimeout(playMsgSendTimer)
playMsgSendTimer = setTimeout(() => {
const audio = new Audio(msgSend)
audio.play().catch(() => {
// do nothing
})
}, 300)
}

View File

@@ -1,3 +1,5 @@
import { pinyin } from 'pinyin-pro'
export const maskPhoneNum = (str) => {
if (str.length < 7) {
return '*'
@@ -162,6 +164,16 @@ export const showTimeFormatDay = (datatime) => {
return `${year}-${month}-${day}`
}
export const showDurationFormat = (duration) => {
if (!duration) {
return '0:00'
}
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
export const combineId = (fromId, toId) => {
if (fromId < toId) {
return fromId + '@' + toId
@@ -239,3 +251,57 @@ export const formatFileSize = (size) => {
return (size / (1024 * 1024)).toFixed(2) + ' MB'
}
}
/**
* 多功能匹配:忽略大小写,字符匹配,拼音匹配,拼音缩写匹配
* @param {*} content 匹配内容
* @param {*} key 关键字
* @returns
*/
export const smartMatch = (content, key) => {
const lowerKey = key.toLowerCase()
const lowerContent = content.toLowerCase()
const pinyinFull = getFullPinyin(content)
const pinyinInitials = getInitialsPinyin(content)
return (
lowerContent.includes(lowerKey) ||
pinyinFull.includes(lowerKey) ||
pinyinInitials.includes(lowerKey)
)
}
/**
* 基础匹配:忽略大小写
* @param {*} content 匹配内容
* @param {*} key 关键字
* @returns
*/
export const baseMatch = (content, key) => {
const lowerKey = key.toLowerCase()
const lowerContent = content.toLowerCase()
return lowerContent.includes(lowerKey)
}
/**
* 汉字转全拼(小写,无空格)
* @param {*} name
* @returns
*/
const getFullPinyin = (name) => {
return pinyin(name, { toneType: 'none', type: 'string' }).replaceAll(' ', '').toLowerCase()
}
/**
* 获取拼音首字母(小写,无空格)
* @param {*} name
* @returns
*/
const getInitialsPinyin = (name) => {
return pinyin(name, {
pattern: 'first',
toneType: 'none',
type: 'array'
})
.join('')
.toLowerCase()
}

View File

@@ -97,25 +97,3 @@ export const getEmojiHtml = (emojiId) => {
return emojiId
}
}
/**
* 把消息内容中的"[xxx]"替换为img html元素
* @param {*} content 消息内容
*/
export const emojiTrans = (content) => {
const pattern = /\[(.*?)\]/g
const matches = content.match(pattern)
if (!matches || matches.length === 0) {
return content
}
new Set(matches).forEach((item) => {
const url = emojis[item]
if (url) {
const emojiHtml = `<img class='emoji' alt='${item}':' title='${item.slice(1, -1)}' src='${url}'>`
content = content.replaceAll(item, emojiHtml)
}
})
return content
}

17
src/js/utils/file.js Normal file
View File

@@ -0,0 +1,17 @@
import CryptoJS from 'crypto-js'
export const getMd5 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = (e) => {
const arrayBuffer = e.target.result
const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer)
const md5 = CryptoJS.MD5(wordArray).toString()
resolve(md5)
}
reader.onerror = () => {
reject(new Error('读取文件md5值失败'))
}
})
}

View File

@@ -1,5 +1,3 @@
import { ElMessage } from 'element-plus'
/**
* 流控在duration时间内只允许task任务被执行countLimit次
* @param {*} task 待执行的任务Promise可以是请求或者其他
@@ -11,8 +9,7 @@ export const flowLimiteWrapper = (task, countLimit, duration) => {
let count = 0
return async () => {
if (count >= countLimit) {
ElMessage.warning('请求太过频繁,请稍后再试')
return Promise.reject(new Error('REQUEST_LIMITED')) // 返回一个拒绝的 Promise
return Promise.reject(new Error('请求太过频繁,请稍后再试')) // 返回一个拒绝的 Promise
}
count++

91
src/js/utils/image.js Normal file
View File

@@ -0,0 +1,91 @@
import { THUMB_IMAGE_MAX } from '@/const/mtsConst'
/**
* 生成缩略图
* @param {*}
* @returns 缩略图file对象原图宽高缩略图宽高
*/
export const prehandleImage = async (blob, originalWidth = null, originalHeight = null) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(blob)
reader.onload = () => {
const img = new Image()
img.src = reader.result
img.onload = () => {
let width = originalWidth !== null ? originalWidth : img.width
let height = originalHeight !== null ? originalHeight : img.height
let thumbWidth = img.width
let thumbHeight = img.height
if (blob.size <= THUMB_IMAGE_MAX) {
resolve({
blob,
originWidth: width,
originHeight: height,
thumbWidth,
thumbHeight
})
}
let accuracy = getAccuracy(blob.size)
const scaleRatio = Math.sqrt(accuracy) // 根据 accuracy的平方根 计算缩放比例
// 等比率缩放宽高
thumbWidth = Math.floor(thumbWidth * scaleRatio)
thumbHeight = Math.floor(thumbHeight * scaleRatio)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = thumbWidth
canvas.height = thumbHeight
ctx.drawImage(img, 0, 0, thumbWidth, thumbHeight)
canvas.toBlob(
async (blob) => {
if (blob) {
if (blob.size <= THUMB_IMAGE_MAX) {
const thumbFile = new File([blob], blob.name, {
type: blob.type
})
resolve({
thumbFile,
originWidth: width,
originHeight: height,
thumbWidth,
thumbHeight
})
} else {
const result = await prehandleImage(blob, width, height)
resolve(result)
}
} else {
reject(new Error('生成缩略图遇到了问题'))
}
},
blob.type,
accuracy
)
}
img.onerror = () => {
reject(new Error('加载图片失败'))
}
}
reader.onerror = () => {
reject(new Error('读取图片失败'))
}
})
}
// 自动调节精度(经验数值)
function getAccuracy(size) {
let accuracy
if (size < 1024 * 1024) {
accuracy = 0.6
} else if (size < 2047 * 1024) {
accuracy = 0.5
} else if (size < 3275 * 1024) {
accuracy = 0.45
} else {
accuracy = 0.4
}
return accuracy
}

217
src/js/utils/message.js Normal file
View File

@@ -0,0 +1,217 @@
import { msgContentType } from '@/const/msgConst'
import { jsonParseSafe, showDurationFormat } from './common'
import { useImageStore, useAudioStore, useVideoStore, useDocumentStore } from '@/stores'
import { emojis } from './emojis'
const imageData = useImageStore()
const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
export const showSimplifyMsgContent = (content) => {
const arr = jsonParseSafe(content)
if (!arr || !Array.isArray(arr) || arr.length === 0) {
return content
}
let simplifyContent = ''
for (const item of arr) {
if (!item.type || !item.value) {
return content
}
switch (item.type) {
case msgContentType.TEXT:
case msgContentType.EMOJI:
simplifyContent = simplifyContent + item.value
break
case msgContentType.AT:
simplifyContent = simplifyContent + `@${item.value.nickName} `
break
case msgContentType.SCREENSHOT:
simplifyContent = simplifyContent + `[截图]`
break
case msgContentType.QUOTE:
simplifyContent = simplifyContent + '[引用]'
break
case msgContentType.RECORDING:
simplifyContent =
simplifyContent + `[语音] ${showDurationFormat(audioData.audio[item.value]?.duration)}`
break
case msgContentType.IMAGE:
simplifyContent = simplifyContent + `[图片] ${imageData.image[item.value]?.fileName}`
break
case msgContentType.AUDIO:
simplifyContent = simplifyContent + `[音频] ${audioData.audio[item.value]?.fileName}`
break
case msgContentType.VIDEO:
simplifyContent = simplifyContent + `[视频] ${videoData.video[item.value]?.fileName}`
break
case msgContentType.DOCUMENT:
simplifyContent = simplifyContent + `[文件] ${documentData.document[item.value]?.fileName}`
break
case msgContentType.FORWARD:
simplifyContent = simplifyContent + '[聊天记录]'
break
default:
simplifyContent = simplifyContent + item.value
break
}
}
return simplifyContent
}
/**
* 内容字符串是否匹配消息结构
*/
export const isMatchMsgStruct = (contentStr) => {
const contentArr = jsonParseSafe(contentStr)
if (!contentArr || !Array.isArray(contentArr) || contentArr.length === 0) {
return false
}
for (const item of contentArr) {
const { type, value } = item
if (!type || !value) {
return false
}
switch (type) {
case msgContentType.TEXT:
break
case msgContentType.EMOJI:
if (!(value in emojis)) {
return false
}
break
case msgContentType.SCREENSHOT:
case msgContentType.IMAGE:
case msgContentType.RECORDING:
case msgContentType.AUDIO:
case msgContentType.VIDEO:
case msgContentType.DOCUMENT:
if (!/^\d+$/.test(value)) {
return false
}
break
case msgContentType.AT: {
const { account, nickName } = value
if (!account || !nickName) {
return false
}
break
}
case msgContentType.QUOTE: {
const { msgId, nickName } = value
if (!msgId || !nickName || !/^\d+$/.test(msgId)) {
return false
}
break
}
case msgContentType.FORWARD: {
const { sessionId, data } = value
if (!sessionId || !data) {
return false
}
if (Array.isArray(data)) {
return
}
for (const item of data) {
const { msgId, nickName } = item
if (!msgId || !nickName || !/^\d+$/.test(msgId)) {
return false
}
}
break
}
default:
return false
}
}
return true
}
/**
* 是否为 MIX 类型
* @param {*} type
* @returns
*/
export const isMixType = (type) => {
const MIX_CANDIDATES =
msgContentType.TEXT |
msgContentType.EMOJI |
msgContentType.SCREENSHOT |
msgContentType.AT |
msgContentType.QUOTE
return type <= MIX_CANDIDATES
}
/**
* 所有包含图片的type集合
* @returns
*/
export const imageTypes = () => {
return [
msgContentType.IMAGE,
msgContentType.SCREENSHOT,
msgContentType.SCREENSHOT | msgContentType.TEXT,
msgContentType.SCREENSHOT | msgContentType.EMOJI,
msgContentType.SCREENSHOT | msgContentType.AT,
msgContentType.SCREENSHOT | msgContentType.QUOTE,
msgContentType.SCREENSHOT | msgContentType.TEXT | msgContentType.EMOJI,
msgContentType.SCREENSHOT | msgContentType.TEXT | msgContentType.AT,
msgContentType.SCREENSHOT | msgContentType.TEXT | msgContentType.QUOTE,
msgContentType.SCREENSHOT | msgContentType.EMOJI | msgContentType.AT,
msgContentType.SCREENSHOT | msgContentType.EMOJI | msgContentType.QUOTE,
msgContentType.SCREENSHOT | msgContentType.AT | msgContentType.QUOTE,
msgContentType.SCREENSHOT | msgContentType.TEXT | msgContentType.EMOJI | msgContentType.AT,
msgContentType.SCREENSHOT | msgContentType.TEXT | msgContentType.AT | msgContentType.QUOTE,
msgContentType.SCREENSHOT | msgContentType.EMOJI | msgContentType.AT | msgContentType.QUOTE,
msgContentType.SCREENSHOT | msgContentType.TEXT | msgContentType.EMOJI | msgContentType.QUOTE,
msgContentType.SCREENSHOT |
msgContentType.TEXT |
msgContentType.EMOJI |
msgContentType.AT |
msgContentType.QUOTE
]
}
/**
* 所有包含Quote的type集合
* @returns
*/
export const quoteTypes = () => {
return [
msgContentType.QUOTE,
msgContentType.QUOTE | msgContentType.TEXT,
msgContentType.QUOTE | msgContentType.EMOJI,
msgContentType.QUOTE | msgContentType.AT,
msgContentType.QUOTE | msgContentType.SCREENSHOT,
msgContentType.QUOTE | msgContentType.TEXT | msgContentType.EMOJI,
msgContentType.QUOTE | msgContentType.TEXT | msgContentType.AT,
msgContentType.QUOTE | msgContentType.TEXT | msgContentType.SCREENSHOT,
msgContentType.QUOTE | msgContentType.EMOJI | msgContentType.AT,
msgContentType.QUOTE | msgContentType.EMOJI | msgContentType.SCREENSHOT,
msgContentType.QUOTE | msgContentType.AT | msgContentType.SCREENSHOT,
msgContentType.QUOTE | msgContentType.EMOJI | msgContentType.AT | msgContentType.SCREENSHOT,
msgContentType.QUOTE | msgContentType.TEXT | msgContentType.AT | msgContentType.SCREENSHOT,
msgContentType.QUOTE | msgContentType.TEXT | msgContentType.EMOJI | msgContentType.SCREENSHOT,
msgContentType.QUOTE | msgContentType.TEXT | msgContentType.EMOJI | msgContentType.AT,
msgContentType.QUOTE |
msgContentType.TEXT |
msgContentType.EMOJI |
msgContentType.AT |
msgContentType.SCREENSHOT
]
}

View File

@@ -62,13 +62,13 @@ instance.interceptors.response.use(
res.data?.code,
res.data?.desc
)
return Promise.reject(res.data)
return Promise.reject(res)
},
async (err) => {
if (err.response?.status === 401) {
useUserStore().clearAt()
useUserStore().clearRt()
ElMessage.error('您还未登录,请先登录')
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
} else {
console.error(

21
src/js/utils/video.js Normal file
View File

@@ -0,0 +1,21 @@
export const prehandleVideo = async (blob) => {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob)
const video = document.createElement('video')
video.preload = 'metadata'
video.onloadedmetadata = () => {
URL.revokeObjectURL(url)
resolve({
width: video.videoWidth,
height: video.videoHeight
})
}
video.onerror = () => {
reject(new Error('视频文件元数据加载失败'))
}
video.src = url
})
}

View File

@@ -3,7 +3,7 @@ import { proto } from '@/const/msgConst'
import { useUserStore } from '@/stores'
import { v4 as uuidv4 } from 'uuid'
export const chatConstructor = (sessionId, toId, content, seq) => {
export const chatConstructor = ({ sessionId, remoteId, content, contentType, sequence }) => {
const header = Header.create({
magic: proto.magic,
version: proto.version,
@@ -15,10 +15,11 @@ export const chatConstructor = (sessionId, toId, content, seq) => {
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
toId: toId,
toId: remoteId,
sessionId: sessionId,
content: content,
seq: seq
contentType: contentType,
seq: sequence
})
const chatMsg = Msg.create({ header: header, body: body })
const payload = Msg.encode(chatMsg).finish()
@@ -27,7 +28,7 @@ export const chatConstructor = (sessionId, toId, content, seq) => {
return data
}
export const groupChatConstructor = (sessionId, groupId, content, seq) => {
export const groupChatConstructor = ({ sessionId, remoteId, content, contentType, sequence }) => {
const header = Header.create({
magic: proto.magic,
version: proto.version,
@@ -40,9 +41,10 @@ export const groupChatConstructor = (sessionId, groupId, content, seq) => {
fromId: userData.user.account,
fromClient: userData.clientId,
sessionId: sessionId,
groupId: groupId,
groupId: remoteId,
content: content,
seq: seq
contentType: contentType,
seq: sequence
})
const msg = Msg.create({ header: header, body: body })
const payload = Msg.encode(msg).finish()
@@ -79,7 +81,7 @@ export const helloConstructor = () => {
return data
}
export const chatReadConstructor = (sessionId, toId, content) => {
export const chatReadConstructor = ({ sessionId, remoteId, content }) => {
const header = Header.create({
magic: proto.magic,
version: proto.version,
@@ -91,7 +93,7 @@ export const chatReadConstructor = (sessionId, toId, content) => {
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
toId: toId,
toId: remoteId,
sessionId: sessionId,
content: content,
seq: uuidv4()
@@ -103,7 +105,7 @@ export const chatReadConstructor = (sessionId, toId, content) => {
return data
}
export const groupChatReadConstructor = (sessionId, groupId, content) => {
export const groupChatReadConstructor = ({ sessionId, remoteId, content }) => {
const header = Header.create({
magic: proto.magic,
version: proto.version,
@@ -115,7 +117,7 @@ export const groupChatReadConstructor = (sessionId, groupId, content) => {
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
toId: groupId,
toId: remoteId,
sessionId: sessionId,
content: content,
seq: uuidv4()
@@ -169,6 +171,30 @@ export const statusSyncConstructor = (status) => {
return data
}
export const atConstructor = ({ sessionId, remoteId, content, sequence }) => {
const header = Header.create({
magic: proto.magic,
version: proto.version,
msgType: MsgType.AT,
isExtension: false
})
const userData = useUserStore()
const body = Body.create({
fromId: userData.user.account,
fromClient: userData.clientId,
sessionId: sessionId,
groupId: remoteId,
content: content,
seq: sequence
})
const chatMsg = Msg.create({ header: header, body: body })
const payload = Msg.encode(chatMsg).finish()
const data = encodePayload(payload)
return data
}
/**
* 发送前对长度编码,配合服务端解决半包黏包问题
* @param {*} payload

View File

@@ -10,7 +10,8 @@ import {
statusReqConstructor,
statusSyncConstructor,
groupChatConstructor,
groupChatReadConstructor
groupChatReadConstructor,
atConstructor
} from './constructor'
import {
onReceiveStatusResMsg,
@@ -18,7 +19,10 @@ import {
onReceiveChatReadMsg,
onReceiveGroupChatMsg,
onReceiveGroupChatReadMsg,
onReceiveGroupSystemMsg
onReceiveGroupSystemMsg,
onReceiveAtMsg,
onReceiveRevokeMsg,
onReceiveDeleteMsg
} from '@/js/event'
class WsConnect {
@@ -118,6 +122,9 @@ class WsConnect {
[MsgType.CHAT_READ]: onReceiveChatReadMsg(),
[MsgType.GROUP_CHAT]: onReceiveGroupChatMsg(),
[MsgType.GROUP_CHAT_READ]: onReceiveGroupChatReadMsg(),
[MsgType.AT]: onReceiveAtMsg(),
[MsgType.REVOKE]: onReceiveRevokeMsg(),
[MsgType.DELETE]: onReceiveDeleteMsg(),
[MsgType.SYS_GROUP_CREATE]: onReceiveGroupSystemMsg(),
[MsgType.SYS_GROUP_ADD_MEMBER]: onReceiveGroupSystemMsg(),
[MsgType.SYS_GROUP_DEL_MEMBER]: onReceiveGroupSystemMsg(),
@@ -149,7 +156,8 @@ class WsConnect {
[MsgType.GROUP_CHAT]: groupChatConstructor,
[MsgType.GROUP_CHAT_READ]: groupChatReadConstructor,
[MsgType.STATUS_REQ]: statusReqConstructor,
[MsgType.STATUS_SYNC]: statusSyncConstructor
[MsgType.STATUS_SYNC]: statusSyncConstructor,
[MsgType.AT]: atConstructor
}
/**
@@ -335,10 +343,16 @@ class WsConnect {
* @param {*} before 发送前的处理,用于展示发送前状态
* @param {*} after 发送后(接收MsgType.DELIVERED时)的处理,用于展示发送后状态
*/
sendMsg(sessionId, remoteId, msgType, content, seq, before, after) {
sendMsg(sessionId, remoteId, msgType, content, contentType, seq, before, after) {
const sequence = seq || uuidv4()
const data = this.dataConstructor[msgType](sessionId, remoteId, content, sequence)
before(sequence, data)
const data = this.dataConstructor[msgType]({
sessionId,
remoteId,
content,
contentType,
sequence
})
before(data)
this.msgIdRefillCallback[sequence] = after
this.sendAgent(data)
}

91
src/models/message.js Normal file
View File

@@ -0,0 +1,91 @@
import { msgSendStatus } from '@/const/msgConst'
/**
* 消息渲染及缓存时用到实体类,收录所有可能用到的属性,目前作为参考用,并未实际调用
*/
class Message {
/**
* 会话内唯一消息Id
*/
msgId
/**
* 消息序列号
*/
seq
/**
* 消息所属的会话ID
*/
sessionId
/**
* 消息发送ID
*/
fromId
/**
* 消息类型
*/
msgType
/**
* 消息内容
*/
content
/**
* 消息状态:发送中,发送成功,发送失败
*/
status
/**
* 接收消息的时间
*/
msgTime
/**
* 消息发送的时间,发送消息时才需要填
*/
sendTime
/**
* 消息中文件的上传状态
*/
uploadStatus
/**
* 消息中文件的上传进度
*/
uploadProgress
constructor(
sessionId,
fromId,
msgType,
content,
contentType,
msgTime,
sendTime = undefined,
msgId = undefined,
seq = undefined,
status = msgSendStatus.PENDING,
uploadStatus = undefined,
uploadProgress = undefined
) {
this.msgId = msgId
this.seq = seq
this.sessionId = sessionId
this.fromId = fromId
this.msgType = msgType
this.content = content
this.contentType = contentType
this.status = status
this.msgTime = msgTime
this.sendTime = sendTime
this.uploadStatus = uploadStatus
this.uploadProgress = uploadProgress
}
}
export { Message }

View File

@@ -293,6 +293,9 @@ export const Msg = ($root.Msg = (() => {
* @property {number} STATUS_REQ=8 STATUS_REQ value
* @property {number} STATUS_RES=9 STATUS_RES value
* @property {number} STATUS_SYNC=10 STATUS_SYNC value
* @property {number} AT=11 AT value
* @property {number} REVOKE=12 REVOKE value
* @property {number} DELETE=13 DELETE value
* @property {number} SYS_GROUP_CREATE=21 SYS_GROUP_CREATE value
* @property {number} SYS_GROUP_ADD_MEMBER=22 SYS_GROUP_ADD_MEMBER value
* @property {number} SYS_GROUP_DEL_MEMBER=23 SYS_GROUP_DEL_MEMBER value
@@ -329,6 +332,9 @@ export const MsgType = ($root.MsgType = (() => {
values[(valuesById[8] = 'STATUS_REQ')] = 8
values[(valuesById[9] = 'STATUS_RES')] = 9
values[(valuesById[10] = 'STATUS_SYNC')] = 10
values[(valuesById[11] = 'AT')] = 11
values[(valuesById[12] = 'REVOKE')] = 12
values[(valuesById[13] = 'DELETE')] = 13
values[(valuesById[21] = 'SYS_GROUP_CREATE')] = 21
values[(valuesById[22] = 'SYS_GROUP_ADD_MEMBER')] = 22
values[(valuesById[23] = 'SYS_GROUP_DEL_MEMBER')] = 23
@@ -543,6 +549,9 @@ export const Header = ($root.Header = (() => {
case 8:
case 9:
case 10:
case 11:
case 12:
case 13:
case 21:
case 22:
case 23:
@@ -635,6 +644,18 @@ export const Header = ($root.Header = (() => {
case 10:
message.msgType = 10
break
case 'AT':
case 11:
message.msgType = 11
break
case 'REVOKE':
case 12:
message.msgType = 12
break
case 'DELETE':
case 13:
message.msgType = 13
break
case 'SYS_GROUP_CREATE':
case 21:
message.msgType = 21
@@ -798,6 +819,7 @@ export const Body = ($root.Body = (() => {
* @property {string|null} [groupId] Body groupId
* @property {number|Long|null} [msgId] Body msgId
* @property {string|null} [content] Body content
* @property {number|null} [contentType] Body contentType
* @property {string|null} [seq] Body seq
* @property {string|null} [sessionId] Body sessionId
*/
@@ -815,21 +837,23 @@ export const Body = ($root.Body = (() => {
* | 5 | groupId | - | - | - | - | M | M | - | M | - | todo | todo |
* | 6 | msgId | - | - | - | M | - | M | O | O | M | todo | todo |
* | 7 | content | - | - | M | M | M | M | M | M | - | todo | todo |
* | 8 | seq | - | - | M | M | M | M | O | O | M | todo | todo |
* | 9 | sessionId | - | - | M | M | M | M | M | M | M | todo | todo |
* | 8 | contentType | - | - | M | M | M | M | - | - | - | todo | todo |
* | 9 | seq | - | - | M | M | M | M | O | O | M | todo | todo |
* |10 | sessionId | - | - | M | M | M | M | M | M | M | todo | todo |
* +---+--------------+------+-----------+---------|-----------+---------------+-----------------+----------+----------------+----------+-------------------+---------------------+
* NO filed STATUS_REQ STATUS_RES STATUS_SYNC SYS_GROUP_XXX
* +---+--------------+------------+------------+-------------+------------+
* | 1 | fromId | M | M | M | - |
* | 2 | fromClient | M | M | M | - |
* | 3 | toId | - | - | - | - |
* | 4 | toClient | - | - | - | - |
* | 5 | groupId | - | - | - | M |
* | 6 | msgId | - | - | - | M |
* | 7 | content | M | M | M | M |
* | 8 | seq | - | - | - | - |
* | 9 | sessionId | - | - | - | M |
* +---+--------------+------------+------------+-------------+------------+
* NO filed STATUS_REQ STATUS_RES STATUS_SYNC SYS_GROUP_XXX AT(up) AT(down) REVOKE DELETE
* +---+--------------+------------+------------+-------------+------------+---------+---------+-----------+-----------+
* | 1 | fromId | M | M | M | - | M | M | M | M |
* | 2 | fromClient | M | M | M | - | M | M | - | - |
* | 3 | toId | - | - | - | - | - | M | o | M |
* | 4 | toClient | - | - | - | - | - | M | - | M |
* | 5 | groupId | - | - | - | M | M | M | o | - |
* | 6 | msgId | - | - | - | M | - | M | M | M |
* | 7 | content | M | M | M | M | M | M | M | - |
* | 8 | contentType | - | - | - | - | - | - | - | - |
* | 9 | seq | - | - | - | - | M | M | - | - |
* |10 | sessionId | - | - | - | M | M | M | M | M |
* +---+--------------+------------+------------+-------------+------------+---------+---------+-----------+-----------+
* @implements IBody
* @constructor
* @param {IBody=} [properties] Properties to set
@@ -896,6 +920,14 @@ export const Body = ($root.Body = (() => {
*/
Body.prototype.content = null
/**
* Body contentType.
* @member {number|null|undefined} contentType
* @memberof Body
* @instance
*/
Body.prototype.contentType = null
/**
* Body seq.
* @member {string|null|undefined} seq
@@ -957,6 +989,12 @@ export const Body = ($root.Body = (() => {
set: $util.oneOfSetter($oneOfFields)
})
// Virtual OneOf for proto3 optional field
Object.defineProperty(Body.prototype, '_contentType', {
get: $util.oneOfGetter(($oneOfFields = ['contentType'])),
set: $util.oneOfSetter($oneOfFields)
})
// Virtual OneOf for proto3 optional field
Object.defineProperty(Body.prototype, '_seq', {
get: $util.oneOfGetter(($oneOfFields = ['seq'])),
@@ -1006,10 +1044,12 @@ export const Body = ($root.Body = (() => {
writer.uint32(/* id 6, wireType 0 =*/ 48).int64(message.msgId)
if (message.content != null && Object.hasOwnProperty.call(message, 'content'))
writer.uint32(/* id 7, wireType 2 =*/ 58).string(message.content)
if (message.contentType != null && Object.hasOwnProperty.call(message, 'contentType'))
writer.uint32(/* id 8, wireType 0 =*/ 64).int32(message.contentType)
if (message.seq != null && Object.hasOwnProperty.call(message, 'seq'))
writer.uint32(/* id 8, wireType 2 =*/ 66).string(message.seq)
writer.uint32(/* id 9, wireType 2 =*/ 74).string(message.seq)
if (message.sessionId != null && Object.hasOwnProperty.call(message, 'sessionId'))
writer.uint32(/* id 9, wireType 2 =*/ 74).string(message.sessionId)
writer.uint32(/* id 10, wireType 2 =*/ 82).string(message.sessionId)
return writer
}
@@ -1073,10 +1113,14 @@ export const Body = ($root.Body = (() => {
break
}
case 8: {
message.seq = reader.string()
message.contentType = reader.int32()
break
}
case 9: {
message.seq = reader.string()
break
}
case 10: {
message.sessionId = reader.string()
break
}
@@ -1150,6 +1194,10 @@ export const Body = ($root.Body = (() => {
properties._content = 1
if (!$util.isString(message.content)) return 'content: string expected'
}
if (message.contentType != null && message.hasOwnProperty('contentType')) {
properties._contentType = 1
if (!$util.isInteger(message.contentType)) return 'contentType: integer expected'
}
if (message.seq != null && message.hasOwnProperty('seq')) {
properties._seq = 1
if (!$util.isString(message.seq)) return 'seq: string expected'
@@ -1187,6 +1235,7 @@ export const Body = ($root.Body = (() => {
object.msgId.high >>> 0
).toNumber()
if (object.content != null) message.content = String(object.content)
if (object.contentType != null) message.contentType = object.contentType | 0
if (object.seq != null) message.seq = String(object.seq)
if (object.sessionId != null) message.sessionId = String(object.sessionId)
return message
@@ -1240,6 +1289,10 @@ export const Body = ($root.Body = (() => {
object.content = message.content
if (options.oneofs) object._content = 'content'
}
if (message.contentType != null && message.hasOwnProperty('contentType')) {
object.contentType = message.contentType
if (options.oneofs) object._contentType = 'contentType'
}
if (message.seq != null && message.hasOwnProperty('seq')) {
object.seq = message.seq
if (options.oneofs) object._seq = 'seq'

View File

@@ -18,6 +18,9 @@ enum MsgType {
STATUS_REQ = 8; //连接状态查询请求
STATUS_RES = 9; //连接状态响应
STATUS_SYNC = 10; //端侧的连接状态同步给云端(比如在线,离开)
AT = 11; //@消息
REVOKE = 12; //撤回消息
DELETE = 13; //删除消息
SYS_GROUP_CREATE = 21; //系统消息之创建群组
SYS_GROUP_ADD_MEMBER = 22; //系统消息之添加群组成员
@@ -62,21 +65,23 @@ message Header {
| 5 | groupId | - | - | - | - | M | M | - | M | - | todo | todo |
| 6 | msgId | - | - | - | M | - | M | O | O | M | todo | todo |
| 7 | content | - | - | M | M | M | M | M | M | - | todo | todo |
| 8 | seq | - | - | M | M | M | M | O | O | M | todo | todo |
| 9 | sessionId | - | - | M | M | M | M | M | M | M | todo | todo |
| 8 | contentType | - | - | M | M | M | M | - | - | - | todo | todo |
| 9 | seq | - | - | M | M | M | M | O | O | M | todo | todo |
|10 | sessionId | - | - | M | M | M | M | M | M | M | todo | todo |
+---+--------------+------+-----------+---------|-----------+---------------+-----------------+----------+----------------+----------+-------------------+---------------------+
NO filed STATUS_REQ STATUS_RES STATUS_SYNC SYS_GROUP_XXX
+---+--------------+------------+------------+-------------+------------+
| 1 | fromId | M | M | M | - |
| 2 | fromClient | M | M | M | - |
| 3 | toId | - | - | - | - |
| 4 | toClient | - | - | - | - |
| 5 | groupId | - | - | - | M |
| 6 | msgId | - | - | - | M |
| 7 | content | M | M | M | M |
| 8 | seq | - | - | - | - |
| 9 | sessionId | - | - | - | M |
+---+--------------+------------+------------+-------------+------------+
NO filed STATUS_REQ STATUS_RES STATUS_SYNC SYS_GROUP_XXX AT(up) AT(down) REVOKE DELETE
+---+--------------+------------+------------+-------------+------------+---------+---------+-----------+-----------+
| 1 | fromId | M | M | M | - | M | M | M | M |
| 2 | fromClient | M | M | M | - | M | M | - | - |
| 3 | toId | - | - | - | - | - | M | o | M |
| 4 | toClient | - | - | - | - | - | M | - | M |
| 5 | groupId | - | - | - | M | M | M | o | - |
| 6 | msgId | - | - | - | M | - | M | M | M |
| 7 | content | M | M | M | M | M | M | M | - |
| 8 | contentType | - | - | - | - | - | - | - | - |
| 9 | seq | - | - | - | - | M | M | - | - |
|10 | sessionId | - | - | - | M | M | M | M | M |
+---+--------------+------------+------------+-------------+------------+---------+---------+-----------+-----------+
*/
message Body {
optional string fromId = 1;
@@ -86,8 +91,9 @@ message Body {
optional string groupId = 5;
optional int64 msgId = 6; //服务端生成的消息ID会话内单调递增可用于消息排序
optional string content = 7;
optional string seq = 8; //客户端生成的序列号ID会话内唯一可用于消息去重
optional string sessionId = 9; //MsgType=SENDER_SYNC需带上该字段因为此时fromId和toId都是发送端的账号无法识别是哪个session
optional int32 contentType = 8;
optional string seq = 9; //客户端生成的序列号ID会话内唯一可用于消息去重
optional string sessionId = 10; //MsgType=SENDER_SYNC需带上该字段因为此时fromId和toId都是发送端的账号无法识别是哪个session
}
message Extension {

View File

@@ -14,47 +14,60 @@ export const useAudioStore = defineStore('anylink-audio', () => {
*/
const audio = ref({})
/**
* 在同一个session中的audioid集合
*/
const audioInSession = ref({})
const setAudio = (sessionId, obj) => {
const setAudio = (obj) => {
audio.value[obj.objectId] = obj
if (!audioInSession.value[sessionId]) {
audioInSession.value[sessionId] = []
}
audioInSession.value[sessionId].push(obj.objectId)
}
const preloadAudio = async (sessionId, msgRecords) => {
/**
* 本地对象ID到服务器对象ID的映射
* 在某些场景下需要通过本地对象ID找到服务器对象ID例如复制刚刚发送的媒体消息
*
*/
const localServerMap = ref({})
const setLocalServerMap = (localObjectId, serverObjectId) => {
localServerMap.value[localObjectId] = serverObjectId
}
const preloadAudioFromMsgList = async (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)
const aar = jsonParseSafe(item.content)
if (!aar || !Array.isArray(aar)) return
aar.forEach((item) => {
if (item.type === msgContentType.AUDIO || item.type === msgContentType.RECORDING) {
const objectId = item.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)
setAudio(item)
})
}
}
const clear = () => {
Object.values(audio.value).forEach((item) => {
if (item.downloadUrl.startsWith('blob:')) {
URL.revokeObjectURL(item.downloadUrl)
}
})
audio.value = {}
}
return {
audio,
audioInSession,
setAudio,
preloadAudio
localServerMap,
setLocalServerMap,
preloadAudioFromMsgList,
clear
}
})

View File

@@ -14,44 +14,60 @@ export const useDocumentStore = defineStore('anylink-document', () => {
*/
const document = ref({})
/**
* 在同一个session中的documentid集合
*/
const documentInSession = ref({})
const setDocument = (sessionId, obj) => {
const setDocument = (obj) => {
document.value[obj.objectId] = obj
if (!documentInSession.value[sessionId]) {
documentInSession.value[sessionId] = []
}
documentInSession.value[sessionId].push(obj.objectId)
}
const preloadDocument = async (sessionId, msgRecords) => {
/**
* 本地对象ID到服务器对象ID的映射
* 在某些场景下需要通过本地对象ID找到服务器对象ID例如复制刚刚发送的媒体消息
*
*/
const localServerMap = ref({})
const setLocalServerMap = (localObjectId, serverObjectId) => {
localServerMap.value[localObjectId] = serverObjectId
}
const preloadDocumentFromMsgList = async (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)
const aar = jsonParseSafe(item.content)
if (!aar || !Array.isArray(aar)) return
aar.forEach((item) => {
if (item.type === msgContentType.DOCUMENT) {
const objectId = item.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)
setDocument(item)
})
}
}
const clear = () => {
Object.values(document.value).forEach((item) => {
if (item.downloadUrl.startsWith('blob:')) {
URL.revokeObjectURL(item.downloadUrl)
}
})
document.value = {}
}
return {
document,
documentInSession,
setDocument,
preloadDocument
localServerMap,
setLocalServerMap,
preloadDocumentFromMsgList,
clear
}
})

View File

@@ -16,7 +16,7 @@ export const useGroupStore = defineStore('anylink-group', () => {
const groupMembersList = ref({})
/**
* 获取有效的成员数组意思是刨除inStatu不等于0的
* 获取有效的成员数组意思是刨除inStatus不等于0的
* @param {*} groupId
*/
const getValidGroupMembers = (groupId) => {

View File

@@ -4,8 +4,6 @@ import { jsonParseSafe } from '@/js/utils/common'
import { defineStore } from 'pinia'
import { ref } from 'vue'
const pattern = /\{[a-f0-9]+\}/g
// image的缓存数据不持久化存储
export const useImageStore = defineStore('anylink-image', () => {
/**
@@ -17,101 +15,113 @@ export const useImageStore = defineStore('anylink-image', () => {
const image = ref({})
/**
* 在同一个session中的imageid集合
* 在同一个session中的需要渲染的image对象
*
* {
* sessionId_01: {objectId_x: {objectId: objectId_x, originUrl: xxx, thumbUrl: xxx}...},
* sessionId_02: {objectId_x: {objectId: objectId_x, originUrl: xxx, thumbUrl: xxx}...},
* }
*/
const imageInSession = ref({})
const setImage = (sessionId, obj) => {
const setImage = (obj) => {
image.value[obj.objectId] = obj
}
const setImageInSession = (sessionId, obj) => {
if (!imageInSession.value[sessionId]) {
imageInSession.value[sessionId] = []
imageInSession.value[sessionId] = {}
}
imageInSession.value[sessionId].push(obj.objectId)
imageInSession.value[sessionId][obj.objectId] = obj
}
const imageTrans = (content, maxWidth = 400, maxHeight = 300) => {
const matches = content.match(pattern)
if (!matches || matches.length === 0) {
return content
const clearImageInSession = (sessionId) => {
if (imageInSession.value[sessionId]) {
imageInSession.value[sessionId] = {}
}
new Set(matches).forEach((item) => {
let startIndex = item.indexOf('{')
let endIndex = item.indexOf('}')
const objectId = item.slice(startIndex + 1, endIndex)
const thumbUrl = image.value[objectId]?.thumbUrl
const originUrl = image.value[objectId]?.originUrl
if (thumbUrl) {
const imageHtml =
`<img class="image" alt="{${objectId}}" src="${thumbUrl}" data-origin-url="${originUrl}" ` +
`style="max-width: ${maxWidth}px; max-height: ${maxHeight}px; width: auto; height: auto;cursor: pointer;">`
content = content.replaceAll(item, imageHtml)
}
})
return content
}
const loadImageInfoFromContent = async (sessionId, content) => {
/**
* 本地对象ID到服务器对象ID的映射
* 在某些场景下需要通过本地对象ID找到服务器对象ID例如复制刚刚发送的媒体消息
*
*/
const localServerMap = ref({})
const setLocalServerMap = (localObjectId, serverObjectId) => {
localServerMap.value[localObjectId] = serverObjectId
}
const preloadImageFromMsg = async (content) => {
if (!content) return
const imageIds = new Set()
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)
const aar = jsonParseSafe(content)
if (!aar || !Array.isArray(aar)) return
aar.forEach((item) => {
if (item.type === msgContentType.SCREENSHOT || item.type === msgContentType.IMAGE) {
const objectId = item.value
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) // 缓存image数据
setImage(item) // 缓存image数据
})
}
}
const preloadImage = async (sessionId, msgRecords) => {
const preloadImageFromMsgList = async (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)
const aar = jsonParseSafe(item.content)
if (!aar || !Array.isArray(aar)) return
aar.forEach((item) => {
if (item.type === msgContentType.SCREENSHOT || item.type === msgContentType.IMAGE) {
const objectId = item.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)
setImage(item)
})
}
}
const clear = () => {
Object.values(image.value).forEach((item) => {
if (item.originUrl.startsWith('blob:')) {
URL.revokeObjectURL(item.originUrl)
}
if (item.thumbUrl.startsWith('blob:')) {
URL.revokeObjectURL(item.thumbUrl)
}
})
image.value = {}
}
return {
image,
imageInSession,
localServerMap,
setLocalServerMap,
setImage,
imageTrans,
loadImageInfoFromContent,
preloadImage
setImageInSession,
clearImageInSession,
preloadImageFromMsg,
preloadImageFromMsgList,
clear
}
})

View File

@@ -16,3 +16,4 @@ export * from './image'
export * from './audio'
export * from './video'
export * from './document'
export * from './menu'

16
src/stores/menu.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useMenuStore = defineStore('anylink-menu', () => {
const activeMenu = ref('') // 当前激活的菜单组件名称
// 设置当前激活的菜单
const setActiveMenu = (menuName) => {
activeMenu.value = menuName
}
return {
activeMenu,
setActiveMenu
}
})

View File

@@ -3,7 +3,8 @@ import { ref, computed, watch } from 'vue'
import {
msgUpdateSessionService,
msgChatSessionListService,
msgQueryPartitionService
msgQueryPartitionService,
msgAtService
} from '@/api/message'
import { ElMessage } from 'element-plus'
import { useImageStore, useAudioStore, useVideoStore, useDocumentStore } from '@/stores'
@@ -30,13 +31,13 @@ export const useMessageStore = defineStore('anylink-message', () => {
* 格式:
* {
* sessionId_1: {
* msgId_1: {msgId: msgId_1, fromId: xxx,...},
* msgId_2: {msgId: msgId_2, fromId: xxx,...},
* msgKey_1: {msgId: msgId_1, fromId: xxx,...}, //msgKey取自msgIdmsgId在发消息之后会更新但msgKey不会
* msgKey_2: {msgId: msgId_2, fromId: xxx,...},
* ...
* }
* sessionId_2: {
* msgId_a: {msgId: msgId_a, fromId: xxx,...},
* msgId_b: {msgId: msgId_b, fromId: xxx,...},
* msgKey_a: {msgId: msgId_a, fromId: xxx,...},
* msgKey_b: {msgId: msgId_b, fromId: xxx,...},
* ...
* }
* ...
@@ -45,15 +46,26 @@ export const useMessageStore = defineStore('anylink-message', () => {
const msgRecordsList = ref({})
/**
* 会话消息ID排序后的数组只存msgId,方便顺序查找
* sessionList中同一会话下的msgKey排序后的数组
* 格式:
* {
* sessionId_1: [msgId_1, msgId_2...],
* sessionId_2: [msgId_a, msgId_b...]
* sessionId_1: [msgKey_1, msgKey_2...],
* sessionId_2: [msgKey_a, msgKey_b...]
* ...
* }
*/
const msgIdSortArray = ref({})
const msgKeySortedArray = ref({})
/**
* @ 消息存储
* 格式:
* {
* sessionId_1: [{msgId:xxx, ...}, {msgId:xxx, ...}],
* sessionId_2: [{msgId:xxx, ...}, {msgId:xxx, ...}]
* ...
* }
*/
const atRecordsList = ref({})
const addSession = (session) => {
sessionList.value[session.sessionId] = session
@@ -117,60 +129,81 @@ export const useMessageStore = defineStore('anylink-message', () => {
}
/**
* 对话列表中加入新的消息数组
* 预加载消息中的媒体资源
* @param {*} sessionId
* @param {*} msgRecords
*/
const preloadResource = async (msgRecords) => {
await useImageStore().preloadImageFromMsgList(msgRecords)
await useAudioStore().preloadAudioFromMsgList(msgRecords)
await useVideoStore().preloadVideoFromMsgList(msgRecords)
await useDocumentStore().preloadDocumentFromMsgList(msgRecords)
}
/**
* 更新msgKey排序
* @param {*} sessionId 会话id
*/
const updateMsgKeySort = (sessionId) => {
// 更新排序
const msgs = msgRecordsList.value[sessionId]
const array = Object.keys(msgs).sort((a, b) => {
const timeA = new Date(msgs[a].sendTime || msgs[a].msgTime).getTime()
const timeB = new Date(msgs[b].sendTime || msgs[b].msgTime).getTime()
return timeA - timeB
})
msgKeySortedArray.value[sessionId] = array
}
/**
* 对话列表中加入新的消息数组(预加载资源)
* @param {*} sessionId 会话id
* @param {*} 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)
const addMsgRecords = (sessionId, msgRecords) => {
if (!msgRecords?.length) return
msgRecords.forEach((item) => {
if (!msgRecordsList.value[sessionId]) {
msgRecordsList.value[sessionId] = {}
msgRecordsList.value[sessionId] = ref({})
}
msgRecordsList.value[sessionId][item.msgId] = item
msgRecordsList.value[sessionId][item.msgId] = ref(item)
})
// 更新排序
const array = Object.values(msgRecordsList.value[sessionId])
array.sort((a, b) => {
const timeA = new Date(a.sendTime || a.msgTime).getTime()
const timeB = new Date(b.sendTime || b.msgTime).getTime()
return timeA - timeB
})
msgIdSortArray.value[sessionId] = array.map((item) => item.msgId)
}
/**
* 移除某个消息消息已发出后用正式消息替换temp消息场景
* @param {*} sessionId 会话id
* @param {*} msgId 消息id
* @param {*} msgKey 消息id
*/
const removeMsgRecord = (sessionId, msgId) => {
if (msgRecordsList.value[sessionId] && msgId in msgRecordsList.value[sessionId]) {
delete msgRecordsList.value[sessionId][msgId]
// 更新排序
const array = Object.values(msgRecordsList.value[sessionId])
array.sort((a, b) => {
const timeA = new Date(a.sendTime || a.msgTime).getTime()
const timeB = new Date(b.sendTime || b.msgTime).getTime()
return timeA - timeB
})
msgIdSortArray.value[sessionId] = array.map((item) => item.msgId)
const removeMsgRecord = (sessionId, msgKey) => {
if (msgRecordsList.value[sessionId] && msgKey in msgRecordsList.value[sessionId]) {
msgRecordsList.value[sessionId][msgKey].delete = true
}
}
const getMsg = (sessionId, msgId) => {
if (!msgRecordsList.value[sessionId] || !msgRecordsList.value[sessionId][msgId]) {
return {}
const revokeMsgRcord = (sessionId, msgKey) => {
if (msgRecordsList.value[sessionId] && msgKey in msgRecordsList.value[sessionId]) {
msgRecordsList.value[sessionId][msgKey].revoke = true
}
return msgRecordsList.value[sessionId][msgId]
}
const getMsg = (sessionId, msgKey) => {
if (!msgRecordsList.value[sessionId] || !msgRecordsList.value[sessionId][msgKey]) {
return ref({})
}
return msgRecordsList.value[sessionId][msgKey]
}
const updateMsg = (sessionId, msgKey, obj) => {
if (!msgRecordsList.value[sessionId] || !msgRecordsList.value[sessionId][msgKey]) {
return
}
if ('msgId' in obj) msgRecordsList.value[sessionId][msgKey].msgId = obj.msgId
if ('status' in obj) msgRecordsList.value[sessionId][msgKey].status = obj.status
if ('msgTime' in obj) msgRecordsList.value[sessionId][msgKey].msgTime = obj.msgTime
if ('sendTime' in obj) msgRecordsList.value[sessionId][msgKey].sendTime = obj.sendTime
updateMsgKeySort(sessionId)
}
const totalUnReadCount = computed(() => {
@@ -180,6 +213,22 @@ export const useMessageStore = defineStore('anylink-message', () => {
)
})
/**
* atRecordsList消息列表中加入新的@ 消息数组
* @param {*} sessionId 会话id
* @param {*} at 新的@ 消息
*/
const addAtRecords = (sessionId, at) => {
if (!at) {
return
}
if (!atRecordsList.value[sessionId]) {
atRecordsList.value[sessionId] = []
}
atRecordsList.value[sessionId].push(at)
}
const el = document.getElementsByTagName('title')[0]
const title = import.meta.env.VITE_TITLE
let task = null
@@ -226,7 +275,9 @@ export const useMessageStore = defineStore('anylink-message', () => {
addSession(res.data.data[item].session)
const msgList = res.data.data[item].msgList
if (msgList) {
await addMsgRecords(item, msgList)
await preloadResource(msgList)
addMsgRecords(item, msgList)
updateMsgKeySort(item)
}
})
}
@@ -252,6 +303,16 @@ export const useMessageStore = defineStore('anylink-message', () => {
}
}
const loadAt = async () => {
msgAtService().then((res) => {
if (res.data.data) {
res.data.data.forEach((item) => {
addAtRecords(item.sessionId, item)
})
}
})
}
const addPartition = (obj) => {
partitions.value[obj.partitionId] = obj
}
@@ -268,12 +329,19 @@ export const useMessageStore = defineStore('anylink-message', () => {
deleteSession,
updateSession,
loadSessionList,
atRecordsList,
addAtRecords,
loadAt,
msgRecordsList,
msgIdSortArray,
msgKeySortedArray,
preloadResource,
updateMsgKeySort,
addMsgRecords,
removeMsgRecord,
revokeMsgRcord,
getMsg,
updateMsg,
partitions,
loadPartitions,

View File

@@ -14,44 +14,60 @@ export const useVideoStore = defineStore('anylink-video', () => {
*/
const video = ref({})
/**
* 在同一个session中的videoid集合
*/
const videoInSession = ref({})
const setVideo = (sessionId, obj) => {
const setVideo = (obj) => {
video.value[obj.objectId] = obj
if (!videoInSession.value[sessionId]) {
videoInSession.value[sessionId] = []
}
videoInSession.value[sessionId].push(obj.objectId)
}
const preloadVideo = async (sessionId, msgRecords) => {
/**
* 本地对象ID到服务器对象ID的映射
* 在某些场景下需要通过本地对象ID找到服务器对象ID例如复制刚刚发送的媒体消息
*
*/
const localServerMap = ref({})
const setLocalServerMap = (localObjectId, serverObjectId) => {
localServerMap.value[localObjectId] = serverObjectId
}
const preloadVideoFromMsgList = async (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)
const aar = jsonParseSafe(item.content)
if (!aar || !Array.isArray(aar)) return
aar.forEach((item) => {
if (item.type === msgContentType.VIDEO) {
const objectId = item.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)
setVideo(item)
})
}
}
const clear = () => {
Object.values(video.value).forEach((item) => {
if (item.downloadUrl.startsWith('blob:')) {
URL.revokeObjectURL(item.downloadUrl)
}
})
video.value = {}
}
return {
video,
videoInSession,
setVideo,
preloadVideo
localServerMap,
setLocalServerMap,
preloadVideoFromMsgList,
clear
}
})

View File

@@ -1,9 +1,13 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { CirclePlus, Delete, Edit } from '@element-plus/icons-vue'
import { useMenuStore } from '@/stores'
const emit = defineEmits(['selectMenu'])
const menuData = useMenuStore()
const menuName = 'GroupPartitionOprMenu' // 菜单唯一标识
const menu = computed(() => {
return [
{
@@ -31,23 +35,34 @@ const x = ref(0)
const y = ref(0)
onMounted(() => {
containerRef.value?.addEventListener('contextmenu', handleSessionMenu)
containerRef.value?.addEventListener('contextmenu', handleShowMenu)
document.addEventListener('keydown', handleEscEvent)
document.addEventListener('click', closeMenu) //在其他地方的click事件要能关闭菜单
document.addEventListener('contextmenu', closeMenu) //在其他地方的菜单事件也要能关闭菜单
})
onUnmounted(() => {
containerRef.value?.removeEventListener('contextmenu', handleSessionMenu)
containerRef.value?.removeEventListener('contextmenu', handleShowMenu)
document.removeEventListener('keydown', handleEscEvent)
document.removeEventListener('click', closeMenu)
document.removeEventListener('contextmenu', closeMenu)
})
const handleSessionMenu = (e) => {
// 监听菜单状态变化
watch(
() => menuData.activeMenu,
(newVal) => {
if (newVal !== menuName && isShowMenu.value) {
closeMenu()
}
}
)
const handleShowMenu = (e) => {
e.preventDefault() //阻止浏览器默认行为
e.stopPropagation() // 阻止冒泡
isShowMenu.value = true
menuData.setActiveMenu(menuName)
x.value = e.clientX
y.value = e.clientY
@@ -74,7 +89,7 @@ const handleClick = (item) => {
}
defineExpose({
handleSessionMenu
handleShowMenu
})
</script>

View File

@@ -11,7 +11,7 @@ import {
useUserCardStore,
useGroupCardStore
} from '@/stores'
import { combineId } from '@/js/utils/common'
import { combineId, smartMatch } from '@/js/utils/common'
import { userQueryService } from '@/api/user'
import { ElLoading, ElMessage } from 'element-plus'
import { el_loading_options, PARTITION_TYPE } from '@/const/commonConst'
@@ -112,12 +112,10 @@ const showData = computed(() => {
Object.values(initData.value).forEach((item) => {
// 1.放群名称和群ID或群备注的匹配结果
if (
item.groupName.toLowerCase().includes(searchKey.value.toLowerCase()) ||
smartMatch(item.groupName, searchKey.value) ||
item.groupId === searchKey.value ||
(props.tab === 'mark' &&
messageData.sessionList[item.groupId].mark
.toLowerCase()
.includes(searchKey.value.toLowerCase()))
smartMatch(messageData.sessionList[item.groupId].mark, searchKey.value))
) {
item['sortMark'] = '1' // 让群名称和群ID的匹配结果放在前面, 因为群成员的匹配结果会滞后出现,如果不排序在出现的时候页面数据刷新变化很大
data.push(item)
@@ -155,7 +153,7 @@ const searchResultTips = computed(() => {
let nickNameMatchCnt = {} // 对同一个群的搜索结果个数计数
searchData.value.forEach((item) => {
const regex = new RegExp(searchKey.value, 'gi')
if (item.nickName.toLowerCase().includes(searchKey.value.toLowerCase())) {
if (smartMatch(item.nickName, searchKey.value)) {
if (item.groupId in nickNameMatchCnt) {
nickNameMatchCnt[item.groupId] = nickNameMatchCnt[item.groupId] + 1
} else {

View File

@@ -15,7 +15,7 @@ import HashNoData from '@/components/common/HasNoData.vue'
import PartitionOprMenu from '@/views/contactList/group/components/PartitionOprMenu.vue'
import EditDialog from '@/components/common/EditDialog.vue'
import SelectGroupDialog from '@/components/common/SelectGroupDialog.vue'
import { highLightedText } from '@/js/utils/common'
import { highLightedText, smartMatch } from '@/js/utils/common'
import { MsgType } from '@/proto/msg'
import { groupInfoService } from '@/api/group'
@@ -77,7 +77,7 @@ const partitionsBySearch = computed(() => {
} else {
const data = {}
Object.values(partitions.value).forEach((item) => {
if (item.partitionName.toLowerCase().includes(partitionSearchKey.value.toLowerCase())) {
if (smartMatch(item.partitionName, partitionSearchKey.value)) {
data[item.partitionId] = item
}
})
@@ -195,7 +195,7 @@ const onCustomContextMenu = (partitionId) => {
const showOperationMenu = (e, partitionId) => {
showOprMenuPartitionId.value = partitionId
oprMenuRef.value.handleSessionMenu(e)
oprMenuRef.value.handleShowMenu(e)
}
const onShowGroupCardFromSelectDialog = async (groupId) => {

View File

@@ -1,9 +1,13 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { CirclePlus, Delete, Edit } from '@element-plus/icons-vue'
import { useMenuStore } from '@/stores'
const emit = defineEmits(['selectMenu'])
const menuData = useMenuStore()
const menuName = 'UserPartitionOprMenu' // 菜单唯一标识
const menu = computed(() => {
return [
{
@@ -31,23 +35,34 @@ const x = ref(0)
const y = ref(0)
onMounted(() => {
containerRef.value?.addEventListener('contextmenu', handleSessionMenu)
containerRef.value?.addEventListener('contextmenu', handleShowMenu)
document.addEventListener('keydown', handleEscEvent)
document.addEventListener('click', closeMenu) //在其他地方的click事件要能关闭菜单
document.addEventListener('contextmenu', closeMenu) //在其他地方的菜单事件也要能关闭菜单
})
onUnmounted(() => {
containerRef.value?.removeEventListener('contextmenu', handleSessionMenu)
containerRef.value?.removeEventListener('contextmenu', handleShowMenu)
document.removeEventListener('keydown', handleEscEvent)
document.removeEventListener('click', closeMenu)
document.removeEventListener('contextmenu', closeMenu)
})
const handleSessionMenu = (e) => {
// 监听菜单状态变化
watch(
() => menuData.activeMenu,
(newVal) => {
if (newVal !== menuName && isShowMenu.value) {
closeMenu()
}
}
)
const handleShowMenu = (e) => {
e.preventDefault() //阻止浏览器默认行为
e.stopPropagation() // 阻止冒泡
isShowMenu.value = true
menuData.setActiveMenu(menuName)
x.value = e.clientX
y.value = e.clientY
@@ -74,7 +89,7 @@ const handleClick = (item) => {
}
defineExpose({
handleSessionMenu
handleShowMenu
})
</script>

View File

@@ -8,6 +8,7 @@ import { el_loading_options } from '@/const/commonConst'
import { Search } from '@element-plus/icons-vue'
import HashNoData from '@/components/common/HasNoData.vue'
import { MsgType } from '@/proto/msg'
import { smartMatch } from '@/js/utils/common'
const messageData = useMessageStore()
const userCardData = useUserCardStore()
@@ -30,7 +31,7 @@ const allData = computed(() => {
data.push(item)
} else {
if (
item.objectInfo.nickName.toLowerCase().includes(searchKey.value.toLowerCase()) ||
smartMatch(item.objectInfo.nickName, searchKey.value) ||
item.objectInfo.account === searchKey.value
) {
data.push(item)
@@ -43,12 +44,12 @@ const allData = computed(() => {
return data
} else {
return data.sort((a, b) => {
const a_msgIds = messageData.msgIdSortArray[a.sessionId]
const a_msgIds = messageData.msgKeySortedArray[a.sessionId]
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 = messageData.msgKeySortedArray[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])

View File

@@ -8,6 +8,7 @@ import { el_loading_options } from '@/const/commonConst'
import { Search } from '@element-plus/icons-vue'
import HashNoData from '@/components/common/HasNoData.vue'
import { MsgType } from '@/proto/msg'
import { smartMatch } from '@/js/utils/common'
const messageData = useMessageStore()
const userCardData = useUserCardStore()
@@ -30,9 +31,9 @@ const markData = computed(() => {
data.push(item)
} else {
if (
item.objectInfo.nickName.toLowerCase().includes(markSearchKey.value.toLowerCase()) ||
smartMatch(item.objectInfo.nickName, markSearchKey.value) ||
item.objectInfo.account === markSearchKey.value ||
item.mark.toLowerCase().includes(markSearchKey.value.toLowerCase())
smartMatch(item.mark, markSearchKey.value)
) {
data.push(item)
}

View File

@@ -18,7 +18,7 @@ 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 { combineId, highLightedText, smartMatch } from '@/js/utils/common'
import { MsgType } from '@/proto/msg'
const messageData = useMessageStore()
@@ -73,7 +73,7 @@ const detailData = computed(() => {
data.push(item)
} else {
if (
item.objectInfo.nickName.toLowerCase().includes(userSearchKey.value.toLowerCase()) ||
smartMatch(item.objectInfo.nickName, userSearchKey.value) ||
item.objectInfo.account === userSearchKey.value
)
data.push(item)
@@ -100,7 +100,7 @@ const partitionsBySearch = computed(() => {
} else {
const data = {}
Object.values(partitions.value).forEach((item) => {
if (item.partitionName.toLowerCase().includes(partitionSearchKey.value.toLowerCase())) {
if (smartMatch(item.partitionName, partitionSearchKey.value)) {
data[item.partitionId] = item
}
})
@@ -198,7 +198,7 @@ const onCustomContextMenu = (partitionId) => {
const showOperationMenu = (e, partitionId) => {
showOprMenuPartitionId.value = partitionId
oprMenuRef.value.handleSessionMenu(e)
oprMenuRef.value.handleShowMenu(e)
}
const onShowAddSessionByButton = (partitionId) => {

View File

@@ -8,7 +8,16 @@ import {
} from '@element-plus/icons-vue'
import { onMounted, onUnmounted, ref, computed } from 'vue'
import ContactUs from '@/views/layout/components/ContactUs.vue'
import { useUserStore, useMessageStore, useSearchStore, useGroupStore } from '@/stores'
import {
useUserStore,
useMessageStore,
useSearchStore,
useGroupStore,
useImageStore,
useAudioStore,
useVideoStore,
useDocumentStore
} from '@/stores'
import router from '@/router'
import MyCard from '@/views/layout/components/MyCard.vue'
import NaviMenu from '@/views/layout/components/NaviMenu.vue'
@@ -34,6 +43,10 @@ const userData = useUserStore()
const messageData = useMessageStore()
const searchData = useSearchStore()
const groupData = useGroupStore()
const imageData = useImageStore()
const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
const isShowMyCard = ref(false)
const contactUsRef = ref(null)
const sourceCodeRef = ref(null)
@@ -87,6 +100,10 @@ onUnmounted(() => {
userData.clear()
messageData.clear()
searchData.clear()
imageData.clear()
audioData.clear()
videoData.clear()
documentData.clear()
wsConnect.closeWs()
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
<script setup>
import { msgContentType } from '@/const/msgConst'
import MsgBoxDocument from '@/views/message/components/MsgBoxDocument.vue'
import { watch, onUnmounted } from 'vue'
const props = defineProps(['isShow', 'target', 'contentType', 'fileName', 'fileSize', 'src'])
const emit = defineEmits(['update:isShow', 'confirm'])
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyPress)
})
const handleConfirm = () => {
emit('confirm')
emit('update:isShow', false)
}
const handleClose = () => {
emit('update:isShow', false)
}
const handleKeyPress = (event) => {
if (event.key === 'Enter' && props.isShow) {
handleConfirm()
event.preventDefault()
}
}
watch(
() => props.isShow,
(newVal) => {
if (newVal) {
document.addEventListener('keydown', handleKeyPress)
} else {
document.removeEventListener('keydown', handleKeyPress)
}
}
)
</script>
<template>
<el-dialog
title="发送给:"
:model-value="props.isShow"
width="400"
top="40vh"
@close="handleClose"
>
<div style="display: flex; flex-direction: column; gap: 20px">
<span
class="target"
style="
background-color: #ebedf0;
border-radius: 4px;
padding: 2px 8px 2px 8px;
user-select: text;
"
>{{ props.target }}</span
>
<div class="content" style="display: flex; justify-content: center">
<img
v-if="props.contentType === msgContentType.IMAGE"
:src="props.src"
alt="本地图片加载错误"
style="
max-width: 360px;
max-height: 270px;
object-fit: contain;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
"
/>
<MsgBoxDocument
v-else
:fileName="props.fileName"
:fileSize="props.fileSize"
:contentType="props.contentType"
:use="'agree'"
></MsgBoxDocument>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm">发送</el-button>
</div>
</template>
</el-dialog>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,260 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
import { useUserStore, useMessageStore, useGroupStore } from '@/stores'
import { MsgType } from '@/proto/msg'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import groupIcon from '@/assets/svg/group.svg'
import { smartMatch } from '@/js/utils/common'
const props = defineProps(['modelValue', 'sessionId', 'offsetX', 'offsetY', 'atKey'])
const emit = defineEmits(['update:modelValue', 'selected'])
const userData = useUserStore()
const messageData = useMessageStore()
const groupData = useGroupStore()
const atListRef = ref()
const x = ref(0)
const y = ref(0)
const selectedAtIndex = ref(0) // @列表默认选中的下标
const myAccount = computed(() => userData.user.account)
const session = computed(() => {
return messageData.sessionList[props.sessionId]
})
const atList = computed(() => {
if (session.value.sessionType !== MsgType.GROUP_CHAT) {
return []
}
const members = groupData.groupMembersList[session.value.remoteId]
if (!members) {
return []
}
const list = Object.values(members)
.map((item) => ({
account: item.account,
avatarThumb: item.avatarThumb,
nickName: item.nickName
}))
.filter((item) => item.account !== myAccount.value)
.sort((a, b) => b.account - a.account)
if (members[myAccount.value].role === 2) {
list.unshift({ account: 0, avatarThumb: null, nickName: '所有人' })
}
return list.filter((item) => smartMatch(item.nickName, props.atKey))
})
onMounted(() => {
selectedAtIndex.value = 0
document.addEventListener('click', handleDocumentClick)
document.addEventListener('contextmenu', handleDocumentContextMenu)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('contextmenu', handleDocumentContextMenu)
document.removeEventListener('keydown', handleKeydown)
})
watch(
() => {
return [props.offsetX, props.offsetY, props.atKey]
},
() => {
nextTick(() => {
if (atList.value.length > 0) {
x.value = props.offsetX
y.value = props.offsetY - atListRef.value?.offsetHeight
}
})
}
)
watch(
() => props.modelValue,
(newValue, oldValue) => {
if (newValue && !oldValue) {
selectedAtIndex.value = 0
}
}
)
const handleDocumentClick = () => {
emit('update:modelValue', false) //关闭窗口
}
const handleDocumentContextMenu = () => {
emit('update:modelValue', false) //关闭窗口
}
const handleKeydown = (e) => {
if (!props.modelValue) {
return
}
if (e.key === 'ArrowUp') {
e.preventDefault() // 阻止默认行为,避免输入框内容上的光标移动
if (selectedAtIndex.value > 0) {
selectedAtIndex.value = selectedAtIndex.value - 1
nextTick(scrollToSelectedItem)
} else if (selectedAtIndex.value === 0) {
nextTick(scrollToTop)
}
} else if (e.key === 'ArrowDown') {
e.preventDefault() // 阻止默认行为,避免输入框内容上的光标移动
if (selectedAtIndex.value < atList.value.length - 1) {
selectedAtIndex.value = selectedAtIndex.value + 1
nextTick(scrollToSelectedItem)
} else if (selectedAtIndex.value === atList.value.length - 1) {
nextTick(scrollToButtom)
}
} else if (e.key === 'Enter') {
if (atList.value[selectedAtIndex.value]) {
emit('selected', atList.value[selectedAtIndex.value])
}
emit('update:modelValue', false) //关闭窗口
} else if (e.key === 'Escape') {
emit('update:modelValue', false) //关闭窗口
}
}
// 滚动到选中的项
const scrollToSelectedItem = () => {
const container = atListRef.value
if (!container || selectedAtIndex.value < 0) return
const items = container.querySelectorAll('.at-list-item')
if (items.length > selectedAtIndex.value) {
const selectedItem = items[selectedAtIndex.value]
// 确保选中项在容器视口内
const itemTop = selectedItem.offsetTop
const itemHeight = selectedItem.offsetHeight
const containerHeight = container.clientHeight
const scrollTop = container.scrollTop
if (itemTop < scrollTop) {
// 项在可视区域上方,滚动到顶部对齐
container.scrollTop = itemTop
} else if (itemTop + itemHeight > scrollTop + containerHeight) {
// 项在可视区域下方,滚动到底部对齐
container.scrollTop = itemTop + itemHeight - containerHeight
}
}
}
const scrollToTop = () => {
const container = atListRef.value
if (!container) return
container.scrollTop = 0
}
const scrollToButtom = () => {
const container = atListRef.value
if (!container) return
container.scrollTop = container.scrollHeight
}
const onSelected = (index) => {
emit('selected', atList.value[index])
emit('update:modelValue', false) //关闭窗口
}
</script>
<template>
<Teleport to="body">
<div
v-if="props.modelValue && atList.length > 0"
ref="atListRef"
class="at-list my-scrollbar"
:style="{ left: x + 'px', top: y + 'px' }"
>
<div
v-for="(item, index) in atList"
:key="item.account"
class="at-list-item bdr-b"
:class="{ active: index === selectedAtIndex }"
@click="onSelected(index)"
>
<UserAvatarIcon
v-if="item.account !== 0"
:showName="item.nickName"
:showId="item.account"
:showAvatarThumb="item.avatarThumb"
:userStatus="item.status"
:size="'tiny'"
></UserAvatarIcon>
<div v-else class="all-icon">
<groupIcon></groupIcon>
</div>
<span class="text-ellipsis" :title="item.nickName">{{ item.nickName }}</span>
<span
class="text-ellipsis"
v-if="item.account !== 0"
:title="item.account"
style="color: gray"
>
{{ item.account }}
</span>
</div>
</div>
</Teleport>
</template>
<style lang="scss" scoped>
.at-list {
position: absolute;
width: 160px;
max-height: 160px; // 刚好是5个item的高度
border-radius: 4px;
padding: 4px;
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
overflow-y: scroll;
.at-list-item {
height: 24px;
display: flex;
border-radius: 2px;
padding: 2px;
gap: 5px;
font-size: 14px;
cursor: pointer;
&:hover {
background-color: #c6e2ff;
}
.all-icon {
border: #fff solid 1px;
border-radius: 50%;
background-color: #409eff;
.svg-icon {
height: 24px;
width: 24px;
fill: #fff;
}
}
}
.active {
background-color: #c6e2ff;
}
&.my-scrollbar {
&::-webkit-scrollbar-thumb {
background-color: #409eff;
}
}
}
</style>

View File

@@ -1,62 +0,0 @@
<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,588 @@
<script setup lang="jsx">
import { ref, onMounted, computed, watch, createApp, h } from 'vue'
import { ElDialog, ElLoading, ElIcon } from 'element-plus'
import { Close } from '@element-plus/icons-vue'
import {
useUserStore,
useUserCardStore,
useMessageStore,
useImageStore,
useAudioStore,
useVideoStore,
useDocumentStore
} from '@/stores'
import { showTimeFormat, jsonParseSafe } from '@/js/utils/common'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import { el_loading_options } from '@/const/commonConst'
import { userQueryService } from '@/api/user'
import router from '@/router'
import { msgContentType } from '@/const/msgConst'
import MsgBoxRecording from '@/views/message/components/MsgBoxRecording.vue'
import MsgBoxImage from '@/views/message/components/MsgBoxImage.vue'
import MsgBoxAudio from '@/views/message/components/MsgBoxAudio.vue'
import MsgBoxVideo from '@/views/message/components/MsgBoxVideo.vue'
import MsgBoxDocument from '@/views/message/components/MsgBoxDocument.vue'
import DialogForMsgForward from '@/views/message/components/DialogForMsgForward.vue'
import { emojis } from '@/js/utils/emojis'
import { msgChatQueryMessagesService } from '@/api/message'
import { showSimplifyMsgContent } from '@/js/utils/message'
const props = defineProps(['isShow', 'title', 'sessionId', 'msgs', 'tier'])
const emit = defineEmits(['update:isShow', 'showUserCard', 'close'])
const userData = useUserStore()
const userCardData = useUserCardStore()
const messageData = useMessageStore()
const imageData = useImageStore()
const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
const forwardMsgs = ref({})
const quoteMsg = ref({})
onMounted(async () => {
const loadingInstance = ElLoading.service(el_loading_options)
try {
await messageData.preloadResource(props.msgs)
await loadRelatedMsg()
} finally {
loadingInstance.close()
}
})
/**
* 切换session时要强制关闭比如点击列表中头像 => 弹出的UserCard => 点击发送消息按钮
*/
watch(
() => router.currentRoute.value.query.sessionId,
() => {
onClose()
}
)
const loadRelatedMsg = async () => {
for (const msg of props.msgs) {
const content = msg.content
const arr = jsonParseSafe(content)
if (!arr || !Array.isArray(arr) || arr.length === 0) {
continue
}
for (const item of arr) {
if (item.type === msgContentType.QUOTE) {
// 先从本地消息缓存中获取
const msgFromStore = messageData.getMsg(msg.sessionId, item.value.msgId)
if (!msgFromStore.msgId) {
// 如果本地消息缓存中没有,再去服务器查询
const res = await msgChatQueryMessagesService({
sessionId: msg.sessionId,
msgIds: item.value.msgId
})
if (res.data.data && res.data.data.length > 0) {
quoteMsg.value[msg.msgId] = res.data.data[0]
}
} else {
quoteMsg.value[msg.msgId] = msgFromStore
}
} else if (item.type === msgContentType.FORWARD) {
if (!forwardMsgs.value[msg.msgId]) {
forwardMsgs.value[msg.msgId] = []
}
const forwatdMsgIds = item.value.data.map((item) => item.msgId)
const toQueryMsgIds = []
for (const msgId of forwatdMsgIds) {
// 先从本地消息缓存中获取
const msgFromStore = messageData.getMsg(item.value.sessionId, msgId)
if (!msgFromStore.msgId) {
// 如果本地消息缓存中没有,再去服务器查询
toQueryMsgIds.push(msgId)
} else {
forwardMsgs.value[msg.msgId].push(msgFromStore)
}
}
if (toQueryMsgIds.length > 0) {
const res = await msgChatQueryMessagesService({
sessionId: item.value.sessionId,
msgIds: toQueryMsgIds.join(',')
})
res.data.data.forEach((item) => {
forwardMsgs.value[msg.msgId].push(item)
})
}
}
}
}
}
const myAccount = computed(() => {
return userData.user.account
})
const isMyAccount = (account) => {
return myAccount.value === account
}
const renderContent = ({ msg }) => {
const content = msg.content
const msgId = msg.msgId
const arr = jsonParseSafe(content)
// 不允许非结构化的content
if (!arr || !Array.isArray(arr) || arr.length === 0) {
return <span></span>
}
return arr.map((item) => {
if (!item.type || !item.value) {
return <span></span>
}
switch (item.type) {
case msgContentType.TEXT:
return renderText(item.value)
case msgContentType.EMOJI:
return renderEmoji(item.value)
case msgContentType.SCREENSHOT:
return renderImage(item.value, true)
case msgContentType.AT:
return renderAt(item.value)
case msgContentType.QUOTE:
return renderQuote(item.value, msgId)
case msgContentType.IMAGE:
return renderImage(item.value)
case msgContentType.RECORDING:
return renderRecording(item.value)
case msgContentType.AUDIO:
return renderAudio(item.value)
case msgContentType.VIDEO:
return renderVideo(item.value, msgId)
case msgContentType.DOCUMENT:
return renderDocument(item.value)
case msgContentType.FORWARD:
return renderForwardTogether(item.value, msgId)
default:
return <span></span>
}
})
}
const renderText = (text) => {
return <span>{text}</span>
}
const renderRecording = (audioId) => {
const url = audioData.audio[audioId]?.downloadUrl
const duration = audioData.audio[audioId]?.duration
if (url) {
return <MsgBoxRecording audioUrl={url} duration={duration}></MsgBoxRecording>
} else {
return <span>{'[语音]'}</span>
}
}
const renderAudio = (audioId) => {
const url = audioData.audio[audioId]?.downloadUrl
if (url) {
return (
<MsgBoxAudio
url={url}
fileName={audioData.audio[audioId].fileName}
size={audioData.audio[audioId].size}></MsgBoxAudio>
)
} else {
return <span>{`[${audioId}]`}</span>
}
}
const renderEmoji = (emojiId) => {
const url = emojis[emojiId]
if (url) {
return <img class={'emoji'} src={url} alt={emojiId} title={emojiId.slice(1, -1)}></img>
} else {
return <span>{emojiId}</span>
}
}
const renderImage = (imgId, isScreenShot = false) => {
if (imageData.image[imgId]) {
imageData.setImageInSession(props.sessionId, imageData.image[imgId])
return (
<MsgBoxImage
sessionId={props.sessionId}
imgId={imgId}
isScreenShot={isScreenShot}
thumbWidth={imageData.image[imgId].thumbWidth}
thumbHeight={imageData.image[imgId].thumbHeight}></MsgBoxImage>
)
} else {
return <span>{`[${imgId}]`}</span>
}
}
const renderVideo = (videoId, msgId) => {
const url = videoData.video[videoId]?.downloadUrl
if (url) {
return (
<MsgBoxVideo
msgId={msgId + '-' + new Date().getTime().toString()} // 加个时间戳避免视频播放组件的id冲突
videoId={videoId}
url={url}
fileName={videoData.video[videoId].fileName}
size={videoData.video[videoId].size}
width={videoData.video[videoId].width}
height={videoData.video[videoId].height}></MsgBoxVideo>
)
} else {
return <span>{`[${videoId}]`}</span>
}
}
const renderDocument = (documentId) => {
const url = documentData.document[documentId]?.downloadUrl
if (url) {
return (
<MsgBoxDocument
url={url}
fileName={documentData.document[documentId].fileName}
fileSize={documentData.document[documentId].size}
contentType={documentData.document[documentId].documentType}></MsgBoxDocument>
)
} else {
return <span>{`[${documentId}]`}</span>
}
}
const renderForwardTogether = (forwardContent, msgId) => {
const msgs = forwardMsgs.value[msgId]
if (!msgs) {
return <div class={'forward-together'}></div>
}
// forwardContent(取里面的nickName) 和 msgs合一
const newMsgs = {}
msgs.forEach((item) => {
newMsgs[item.msgId] = item
})
forwardContent.data.forEach((item) => {
if (item.msgId in newMsgs) {
newMsgs[item.msgId] = {
...newMsgs[item.msgId],
...item
}
}
})
const msgsSorted = Object.values(newMsgs).sort((a, b) => {
const timeA = new Date(a.sendTime || a.msgTime).getTime()
const timeB = new Date(b.sendTime || b.msgTime).getTime()
return timeA - timeB
})
if (!msgsSorted) {
return <div class={'forward-together'}></div>
}
const title = '聊天记录'
return (
<div
class={'forward-together'}
onClick={() => {
// 创建挂载容器
const container = document.createElement('div')
document.body.appendChild(container)
const app = createApp({
render: () => {
return h(DialogForMsgForward, {
isShow: true,
title,
sessionId: msgsSorted[0].sessionId,
msgs: msgsSorted,
tier: (props.tier || 0) + 1,
onClose: () => {
app.unmount()
document.body.removeChild(container)
}
})
}
})
// 挂载到新创建的容器
app.mount(container)
}}>
<div class={'main'}>
<span class={'title'}>{title}</span>
<div class={'msg-list'}>
{msgsSorted.map((msg, index) => {
return (
<div key={index} class={'msg-item'}>
<span class={'msg-item-nickname'}>{msg.nickName || msg.fromId}</span>
<span>{''}</span>
<span class={'msg-item-content'}>{showSimplifyMsgContent(msg.content)}</span>
</div>
)
})}
</div>
</div>
<span class={'footer bdr-t'}>{`查看${msgsSorted.length}条转发消息`}</span>
</div>
)
}
const renderAt = (atContent) => {
return <span>{`@${atContent.nickName} `}</span>
}
const renderQuote = (quoteContent, msgId) => {
const { nickName } = quoteContent
const { content, msgTime } = quoteMsg.value[msgId]
? quoteMsg.value[msgId]
: { content: '', msgTime: '' }
// 和InputEditor.vue中的结构保持一致使用相同class可以复用样式
return (
<div class={'quote-block'}>
<div class={'quote-wrapper'}>
<div class={'quote-sender'}>
<span class="quote-nickName">{nickName}</span>
<span class={'quote-msgTime'}>{` ${showTimeFormat(msgTime)}`}</span>
</div>
<span class={'quote-content'}>{showSimplifyMsgContent(content)}</span>
</div>
</div>
)
}
const onClose = () => {
emit('update:isShow', false)
emit('close')
}
const onShowUserCard = (account) => {
const loadingInstance = ElLoading.service(el_loading_options)
if (myAccount.value === account) {
userData
.updateUser()
.then(() => {
userCardData.setUserInfo(userData.user)
userCardData.setIsShow(true)
})
.finally(() => {
loadingInstance.close()
})
} else {
userQueryService({ account: account })
.then((res) => {
userCardData.setUserInfo(res.data.data)
userCardData.setIsShow(true)
})
.finally(() => {
loadingInstance.close()
})
}
}
</script>
<template>
<div class="dialog-msg-list-wrapper">
<el-dialog
class="dialog-msg-list"
:model-value="props.isShow"
:modal="false"
draggable
:width="'600px'"
:top="`${30 + (props.tier || 0)}vh`"
:z-index="1000"
:style="{
minHeight: '360px',
marginLeft: `calc(50% - 300px + ${props.tier || 0} * 1vw)`
}"
:show-close="false"
@closed="onClose"
>
<template #header>
<span class="title bdr-b">{{ props.title }}</span>
<el-icon class="close-button" @click="onClose"><Close /></el-icon>
</template>
<div class="dialog-msg-item-container my-scrollbar">
<div
v-for="item in props.msgs"
:key="item.msgId"
class="dialog-msg-item"
:style="{
flexDirection: isMyAccount(item.fromId) ? 'row-reverse' : 'row',
justifyContent: isMyAccount(item.fromId) ? 'end' : 'start'
}"
>
<div class="dialog-msg-item-avatar">
<UserAvatarIcon
class="avatar-message-item"
:size="'small'"
:showId="item.fromId"
:showName="item.nickName"
@click="onShowUserCard(item.fromId)"
></UserAvatarIcon>
</div>
<div class="dialog-msg-item-main">
<div
class="dialog-msg-item-header"
:style="{
justifyContent: isMyAccount(item.fromId) ? 'end' : 'start'
}"
>
<div class="dialog-msg-item-nickname">{{ item.nickName }}</div>
<div class="dialog-msg-item-time">{{ showTimeFormat(item.msgTime) }}</div>
</div>
<div
class="dialog-msg-item-body"
:style="{
justifyContent: isMyAccount(item.fromId) ? 'end' : 'start'
}"
>
<div
class="dialog-msg-item-content"
:style="{
borderTopLeftRadius: isMyAccount(item.fromId) ? '10px' : '0',
borderTopRightRadius: isMyAccount(item.fromId) ? '0' : '10px',
backgroundColor: isMyAccount(item.fromId) ? '#c6e2ff' : '#dedfe0'
}"
>
<renderContent :msg="item" />
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.dialog-msg-list-wrapper {
:deep(.el-dialog) {
.el-dialog__header {
position: relative;
.title {
width: 100%;
display: flex;
justify-content: center;
padding-bottom: 16px;
font-size: 16px;
}
.close-button {
width: 16px;
height: 16px;
color: gray;
position: absolute;
top: 0;
right: 0;
background: none;
border: none;
cursor: pointer;
z-index: 1;
&:hover {
color: #409eff;
}
}
}
}
.dialog-msg-list {
.dialog-msg-item-container {
max-height: 480px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 5px;
overflow-y: scroll;
.dialog-msg-item {
display: flex;
gap: 5px;
.dialog-msg-item-main {
max-width: 480px;
display: flex;
flex-direction: column;
gap: 4px;
.dialog-msg-item-header {
display: flex;
gap: 5px;
font-size: 12px;
}
.dialog-msg-item-body {
display: flex;
.dialog-msg-item-content {
padding: 8px;
border-radius: 10px;
}
}
}
}
}
}
}
// h函数中动态生成的组件这里的样式需要用deep穿透
:deep(.forward-together) {
width: 240px;
padding: 8px;
border-radius: 4px;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
gap: 10px;
cursor: pointer;
user-select: none;
&:hover {
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.main {
display: flex;
flex-direction: column;
gap: 5px;
.msg-list {
max-height: 72px;
color: gray;
display: flex;
flex-direction: column;
overflow: hidden;
.msg-item {
display: flex;
font-size: 12px;
.msg-item-nickname {
max-width: 80px;
white-space: nowrap; //防止文本自动换行,确保在一行内显示,这样当文本超出宽度时才会触发省略号
overflow: hidden; //当文本超出元素范围时,隐藏超出的部分。
text-overflow: ellipsis; //在文本溢出并且overflow属性设置为hidden时显示省略号。
}
.msg-item-content {
flex: 1;
white-space: nowrap; //防止文本自动换行,确保在一行内显示,这样当文本超出宽度时才会触发省略号
overflow: hidden; //当文本超出元素范围时,隐藏超出的部分。
text-overflow: ellipsis; //在文本溢出并且overflow属性设置为hidden时显示省略号。
}
}
}
}
.footer {
font-size: 12px;
color: gray;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +0,0 @@
<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

@@ -1,102 +0,0 @@
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
<script setup>
import { onMounted, onUnmounted } from 'vue'
import ForwardIcon from '@/assets/svg/forward.svg'
import ForwardoboIcon from '@/assets/svg/forwardobo.svg'
import DeletemsgIcon from '@/assets/svg/deletemsg.svg'
import CancleIcon from '@/assets/svg/cancle.svg'
import { ElMessageBox } from 'element-plus'
const props = defineProps(['selectedCount'])
const emit = defineEmits(['exit', 'forwardTogether', 'forwardOneByOne', 'batchDelete'])
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
cancel()
}
}
const cancel = () => {
emit('exit')
}
defineExpose({ cancel })
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
const handleForwardTogether = () => {
if (props.selectedCount > 0) {
emit('forwardTogether')
}
}
const handleForwardOneByOne = () => {
if (props.selectedCount > 0) {
emit('forwardOneByOne')
}
}
const handleBatchDelete = () => {
if (props.selectedCount > 0) {
ElMessageBox.confirm(`确定删除选中的消息记录吗?`, '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
}).then(() => {
emit('batchDelete')
})
}
}
</script>
<template>
<div class="input-multi-select">
<span class="selected-count">已选中{{ props.selectedCount || 0 }}条消息</span>
<div class="multi-select-funtions">
<div class="function-item">
<div class="fun-icon" @click="handleForwardTogether">
<ForwardIcon></ForwardIcon>
</div>
<span>合并转发</span>
</div>
<div class="function-item">
<div class="fun-icon" @click="handleForwardOneByOne">
<ForwardoboIcon style="width: 20px; height: 20px"></ForwardoboIcon>
</div>
<span>逐条转发</span>
</div>
<div class="function-item">
<div class="fun-icon" @click="handleBatchDelete">
<DeletemsgIcon></DeletemsgIcon>
</div>
<span>批量删除</span>
</div>
<div class="function-item">
<div class="fun-icon" @click="cancel">
<CancleIcon></CancleIcon>
</div>
<span>取消</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.input-multi-select {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
font-size: 14px;
color: gray;
.multi-select-funtions {
display: flex;
gap: 24px;
.function-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
.fun-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background-color: #fff;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: #409eff;
}
}
}
}
}
.svg-icon {
width: 24px;
height: 24px;
// fill: gray;
}
</style>

View File

@@ -1,15 +1,16 @@
<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 { ElMessage } from 'element-plus'
import { useMessageStore, useAudioStore } from '@/stores'
import { mtsUploadService } from '@/api/mts'
import { el_loading_options } from '@/const/commonConst'
import { v4 as uuidv4 } from 'uuid'
import { msgContentType, msgFileUploadStatus, msgSendStatus } from '@/const/msgConst'
import { getMd5 } from '@/js/utils/file'
const props = defineProps(['sessionId'])
const emit = defineEmits(['exit', 'sendRecording'])
const emit = defineEmits(['exit', 'sendMessage', 'saveLocalMsg'])
const messageData = useMessageStore()
const audioData = useAudioStore()
const spaceDown = ref(false) // 空格键是否被按下
const isRecord = ref(false) // 是否开始录音
@@ -143,19 +144,76 @@ const stopRecording = () => {
}
}
const uploadRecord = () => {
const loadingInstance = ElLoading.service(el_loading_options)
const uploadRecord = async () => {
const fileName = `${uuidv4()}.${fileSuffix}`
const file = new File([recordBlob.value], fileName, { type: recordType })
mtsUploadService({ file, storeType: 1, duration: Math.floor(recordDuration / 1000) })
// 发送的时候设置本地缓存(非服务端数据),用于立即渲染
const duration = Math.floor(recordDuration / 1000)
const localSrc = URL.createObjectURL(file)
const tempObjectId = new Date().getTime()
audioData.setAudio({
objectId: tempObjectId,
duration: duration,
downloadUrl: localSrc,
fileName: file.name,
size: file.size
})
let msg = {}
emit('saveLocalMsg', {
content: JSON.stringify([{ type: msgContentType.RECORDING, value: tempObjectId }]),
contentType: msgContentType.RECORDING,
fn: (result) => {
msg = result
}
})
messageData.updateMsg(msg.sessionId, msg.msgId, {
uploadStatus: msgFileUploadStatus.UPLOADING,
uploadProgress: 0
})
const md5 = await getMd5(file)
const files = {
originFile: file
}
const requestBody = {
storeType: 1,
md5,
fileName: file.name,
fileRawType: file.type,
size: file.size,
audioDuration: duration
}
mtsUploadService(requestBody, files)
.then((res) => {
if (res.data.code === 0) {
audioData.setAudio(props.sessionId, res.data.data) // 缓存audio数据
emit('sendRecording', res.data.data)
audioData.setAudio(res.data.data) // 缓存服务端响应的audio数据
audioData.setLocalServerMap(tempObjectId, res.data.data.objectId)
messageData.updateMsg(msg.sessionId, msg.msgId, {
uploadStatus: msgFileUploadStatus.UPLOAD_SUCCESS,
uploadProgress: 100
})
const content = JSON.stringify([
{
type: msgContentType.RECORDING,
value: res.data.data.objectId
}
])
emit('sendMessage', { msg, content })
}
})
.finally(() => {
loadingInstance.close()
.catch((error) => {
messageData.updateMsg(msg.sessionId, msg.msgId, {
uploadStatus: msgFileUploadStatus.UPLOAD_FAILED,
status: msgSendStatus.UPLOAD_FAILED
})
if (error.status === 200 && error.data?.code !== 0) {
ElMessage.error(error.data.desc || '文件上传失败')
} else {
ElMessage.error('文件上传失败')
}
})
}

View File

@@ -1,15 +1,15 @@
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { Clock, Microphone } from '@element-plus/icons-vue'
import { ElMessage, ElLoading } from 'element-plus'
import { ElMessage } 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 CodeIcon from '@/assets/svg/code.svg'
// import VoteIcon from '@/assets/svg/vote.svg'
import EmojiBox from '@/views/message/components/EmojiBox.vue'
import InputTool from '@/views/message/components/InputTool.vue'
import { mtsUploadService } from '@/api/mts'
import { mtsUploadService, mtsUploadServiceForImage } from '@/api/mts'
import {
useMessageStore,
useImageStore,
@@ -17,18 +17,16 @@ import {
useVideoStore,
useDocumentStore
} from '@/stores'
import { el_loading_options } from '@/const/commonConst'
import { MsgType } from '@/proto/msg'
import { msgContentType, msgFileUploadStatus, msgSendStatus } from '@/const/msgConst'
import { prehandleImage } from '@/js/utils/image'
import { prehandleVideo } from '@/js/utils/video'
import { getMd5 } from '@/js/utils/file'
import AgreeBeforeSend from '@/views/message/components/AgreeBeforeSend.vue'
import DialogForMsgHistory from './DialogForMsgHistory.vue'
const props = defineProps(['sessionId', 'isShowToolSet'])
const emit = defineEmits([
'sendEmoji',
'sendImage',
'sendAudio',
'sendVideo',
'sendDocument',
'showRecorder'
])
const emit = defineEmits(['sendEmoji', 'showRecorder', 'sendMessage', 'saveLocalMsg'])
const messageData = useMessageStore()
const imageData = useImageStore()
@@ -36,63 +34,197 @@ const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
const isShowEmojiBox = ref(false)
const showAgreeDialog = ref(false)
const isShowHistoryDialog = ref(false)
const onSelectedFile = (file) => {
const session = computed(() => {
return messageData.sessionList[props.sessionId]
})
const remoteName = computed(() => {
if (session.value.sessionType === MsgType.CHAT) {
return session.value.objectInfo.nickName
} else if (session.value.sessionType === MsgType.GROUP_CHAT) {
return session.value.objectInfo.groupName
} else {
return ''
}
})
let selectedFile
let contentType
let md5
let prehandleImageObj
let prehandleVideoObj
let localSrc
const onSelectedFile = async (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)
}
selectedFile = file
localSrc = URL.createObjectURL(selectedFile.raw)
try {
md5 = await getMd5(file.raw)
if (file.raw.type.startsWith('image/')) {
contentType = msgContentType.IMAGE
prehandleImageObj = await prehandleImage(file.raw)
} else if (file.raw.type.startsWith('audio/')) {
contentType = msgContentType.AUDIO
} else if (file.raw.type.startsWith('video/')) {
contentType = msgContentType.VIDEO
prehandleVideoObj = await prehandleVideo(file.raw)
} else {
contentType = msgContentType.DOCUMENT
}
showAgreeDialog.value = true
} catch (error) {
ElMessage.error(error.message)
URL.revokeObjectURL(localSrc)
return
}
}
const onConfirmSendFile = () => {
// 写本地数据
setLocalData()
// 写本地消息
let msg = {}
emit('saveLocalMsg', {
content: JSON.stringify([{ type: contentType, value: selectedFile.uid }]),
contentType: contentType,
fn: (result) => {
msg = result
}
})
// 上传文件
let requestApi = mtsUploadService
const requestBody = {
storeType: 1,
md5,
fileName: selectedFile.name,
fileRawType: selectedFile.raw.type,
size: selectedFile.raw.size
}
const files = { originFile: selectedFile.raw }
if (contentType === msgContentType.IMAGE) {
requestBody.originWidth = prehandleImageObj.originWidth
requestBody.originHeight = prehandleImageObj.originHeight
requestBody.thumbWidth = prehandleImageObj.thumbWidth
requestBody.thumbHeight = prehandleImageObj.thumbHeight
files.thumbFile = prehandleImageObj.thumbFile
requestApi = mtsUploadServiceForImage
} else if (contentType === msgContentType.VIDEO) {
requestBody.videoWidth = prehandleVideoObj.width
requestBody.videoHeight = prehandleVideoObj.height
}
messageData.updateMsg(msg.sessionId, msg.msgId, {
uploadStatus: msgFileUploadStatus.UPLOADING,
uploadProgress: 0
})
requestApi(requestBody, files)
.then((res) => {
if (res.data.code === 0) {
setStoreData(res.data.data)
messageData.updateMsg(msg.sessionId, msg.msgId, {
uploadStatus: msgFileUploadStatus.UPLOAD_SUCCESS,
uploadProgress: 100
})
const content = JSON.stringify([{ type: contentType, value: res.data.data.objectId }])
emit('sendMessage', { msg, content }) // 上传完成后发网络消息
}
})
.catch((error) => {
messageData.updateMsg(msg.sessionId, msg.msgId, {
uploadStatus: msgFileUploadStatus.UPLOAD_FAILED,
status: msgSendStatus.UPLOAD_FAILED
})
.finally(() => {
loadingInstance.close()
if (error.status === 200 && error.data?.code !== 0) {
ElMessage.error(error.data.desc || '文件上传失败')
} else {
ElMessage.error('文件上传失败')
}
})
}
/**
* 发送的时候设置本地缓存(非服务端数据),用于立即渲染
*/
const setLocalData = () => {
switch (contentType) {
case msgContentType.IMAGE:
imageData.setImage({
objectId: selectedFile.uid,
originUrl: localSrc,
thumbUrl: localSrc, // 本地缓存缩略图用的是原图
fileName: selectedFile.name,
size: selectedFile.raw.size,
thumbWidth: prehandleImageObj.originWidth,
thumbHeight: prehandleImageObj.originHeight,
createdTime: new Date()
})
} 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)
}
break
case msgContentType.AUDIO:
audioData.setAudio({
objectId: selectedFile.uid,
downloadUrl: localSrc,
fileName: selectedFile.name,
size: selectedFile.raw.size
})
.finally(() => {
loadingInstance.close()
break
case msgContentType.VIDEO:
videoData.setVideo({
objectId: selectedFile.uid,
downloadUrl: localSrc,
fileName: selectedFile.name,
size: selectedFile.raw.size,
width: prehandleVideoObj.width,
height: prehandleVideoObj.height
})
} 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()
break
case msgContentType.DOCUMENT:
default:
documentData.setDocument({
objectId: selectedFile.uid,
documentType: selectedFile.raw.type,
downloadUrl: localSrc,
fileName: selectedFile.name,
size: selectedFile.raw.size
})
}
}
/**
* 服务端响应数据回来后设置store缓存
* @param data
*/
const setStoreData = (data) => {
switch (contentType) {
case msgContentType.IMAGE:
imageData.setImage(data)
imageData.setLocalServerMap(selectedFile.uid, data.objectId)
break
case msgContentType.AUDIO:
audioData.setAudio(data)
audioData.setLocalServerMap(selectedFile.uid, data.objectId)
break
case msgContentType.VIDEO:
videoData.setVideo(data)
videoData.setLocalServerMap(selectedFile.uid, data.objectId)
break
case msgContentType.DOCUMENT:
default:
documentData.setDocument(data)
documentData.setLocalServerMap(selectedFile.uid, data.objectId)
}
}
const onSendEmoji = (key) => {
emit('sendEmoji', key)
}
@@ -108,6 +240,10 @@ const showRecorder = () => {
emit('showRecorder')
}
const showHistory = () => {
isShowHistoryDialog.value = true
}
defineExpose({
closeWindow
})
@@ -149,17 +285,17 @@ defineExpose({
<Microphone />
</template>
</InputTool>
<InputTool tips="代码" @click="ElMessage.warning('功能开发中')">
<!-- <InputTool tips="代码" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<CodeIcon />
</template>
</InputTool>
</InputTool> -->
<!-- <InputTool tips="位置" @click="ElMessage.warning('功能开发中')">
<template #iconSlot>
<LocationInformation />
</template>
</InputTool> -->
<InputTool
<!-- <InputTool
v-if="messageData.sessionList[props.sessionId].sessionType === MsgType.GROUP_CHAT"
tips="群投票"
@click="ElMessage.warning('功能开发中')"
@@ -167,10 +303,10 @@ defineExpose({
<template #iconSlot>
<VoteIcon />
</template>
</InputTool>
</InputTool> -->
</div>
<div class="right-tools">
<InputTool tips="聊天记录" @click="ElMessage.warning('功能开发中')">
<InputTool tips="历史消息" @click="showHistory">
<template #iconSlot>
<Clock />
</template>
@@ -182,6 +318,19 @@ defineExpose({
@close="isShowEmojiBox = false"
@sendEmoji="onSendEmoji"
></EmojiBox>
<AgreeBeforeSend
v-model:isShow="showAgreeDialog"
:target="remoteName"
:contentType="contentType"
:fileName="selectedFile?.name"
:fileSize="selectedFile?.raw.size"
:src="localSrc"
@confirm="onConfirmSendFile"
></AgreeBeforeSend>
<DialogForMsgHistory
v-model:isShow="isShowHistoryDialog"
:sessionId="props.sessionId"
></DialogForMsgHistory>
</template>
<style lang="scss" scoped>

View File

@@ -1,9 +1,13 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ChatRound, Microphone, VideoCamera } from '@element-plus/icons-vue'
import { useMenuStore } from '@/stores'
const emit = defineEmits(['selectMenu'])
const menuData = useMenuStore()
const menuName = 'MenuAddOpr' //
const menu = computed(() => {
return [
{
@@ -42,10 +46,21 @@ onUnmounted(() => {
document.removeEventListener('contextmenu', closeMenu)
})
const handleSessionMenu = (e) => {
//
watch(
() => menuData.activeMenu,
(newVal) => {
if (newVal !== menuName && isShowMenu.value) {
closeMenu()
}
}
)
const handleShowMenu = (e) => {
e.preventDefault() //
e.stopPropagation() //
isShowMenu.value = true
menuData.setActiveMenu(menuName)
x.value = e.clientX
y.value = e.clientY
}
@@ -63,7 +78,7 @@ const handleClick = (item) => {
}
defineExpose({
handleSessionMenu
handleShowMenu
})
</script>
@@ -97,6 +112,7 @@ defineExpose({
background-color: #fff;
position: absolute;
box-shadow: 2px 2px 20px gray;
z-index: 1000;
.menu-item {
padding: 5px;

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, markRaw } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, markRaw, watch } from 'vue'
import {
ChatDotRound,
Tickets,
@@ -10,15 +10,17 @@ import {
} 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 DeleteIcon from '@/assets/svg/deleteuser.svg'
import TransferIcon from '@/assets/svg/transfer.svg'
import { useUserStore, useGroupStore } from '@/stores'
import { useUserStore, useGroupStore, useMenuStore } from '@/stores'
const props = defineProps(['groupId', 'account'])
const emit = defineEmits(['selectMenu'])
const userData = useUserStore()
const groupData = useGroupStore()
const menuData = useMenuStore()
const menuName = 'MenuMember' //
const myAccount = computed(() => userData.user.account)
@@ -165,23 +167,34 @@ const x = ref(0)
const y = ref(0)
onMounted(() => {
containerRef.value?.addEventListener('contextmenu', handleSessionMenu)
containerRef.value?.addEventListener('contextmenu', handleShowMenu)
document.addEventListener('keydown', handleEscEvent)
document.addEventListener('click', closeMenu) //click
document.addEventListener('contextmenu', closeMenu) //
})
onUnmounted(() => {
containerRef.value?.removeEventListener('contextmenu', handleSessionMenu)
containerRef.value?.removeEventListener('contextmenu', handleShowMenu)
document.removeEventListener('keydown', handleEscEvent)
document.removeEventListener('click', closeMenu)
document.removeEventListener('contextmenu', closeMenu)
})
const handleSessionMenu = (e) => {
//
watch(
() => menuData.activeMenu,
(newVal) => {
if (newVal !== menuName && isShowMenu.value) {
closeMenu()
}
}
)
const handleShowMenu = (e) => {
e.preventDefault() //
e.stopPropagation() //
isShowMenu.value = true
menuData.setActiveMenu(menuName)
nextTick(() => {
//window.innerWidthx

View File

@@ -0,0 +1,222 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch, markRaw } from 'vue'
import QuoteIcon from '@/assets/svg/quote.svg'
import ForwardIcon from '@/assets/svg/forward.svg'
import DeletemsgIcon from '@/assets/svg/deletemsg.svg'
import CopyIcon from '@/assets/svg/copy.svg'
import MultiselectIcon from '@/assets/svg/multiselect.svg'
import RevokeIcon from '@/assets/svg/revoke.svg'
import { useUserStore, useMenuStore } from '@/stores'
import { MSG_REVOKE_TIME_LIMIT, msgContentType, msgSendStatus } from '@/const/msgConst'
const props = defineProps(['msg'])
const emit = defineEmits(['selectMenu'])
const userData = useUserStore()
const menuData = useMenuStore()
const openMenuTime = ref(null)
const menuName = computed(() => {
return 'MenuMsgItem-' + props.msg.msgId
})
const myAccount = computed(() => {
return userData.user.account
})
const contentType = computed(() => {
return props.msg.contentType
})
const msgStatus = computed(() => {
return props.msg.status || msgSendStatus.OK
})
const menu = computed(() => {
const o = [
{
label: 'delete',
desc: '删除',
icon: markRaw(DeletemsgIcon),
index: 5
}
]
if (
contentType.value !== msgContentType.RECORDING &&
contentType.value !== msgContentType.FORWARD
) {
o.push({
label: 'copy',
desc: '复制',
icon: markRaw(CopyIcon),
index: 0
})
}
if (msgStatus.value === msgSendStatus.OK && contentType.value !== msgContentType.RECORDING) {
o.push({
label: 'forward',
desc: '转发',
icon: markRaw(ForwardIcon),
index: 1
})
o.push({
label: 'multiSelect',
desc: '多选',
icon: markRaw(MultiselectIcon),
index: 2
})
}
if (msgStatus.value === msgSendStatus.OK) {
o.push({
label: 'quote',
desc: '引用',
icon: markRaw(QuoteIcon),
index: 3
})
}
if (
myAccount.value === props.msg.fromId &&
msgStatus.value === msgSendStatus.OK &&
openMenuTime.value - new Date(props.msg.msgTime) < MSG_REVOKE_TIME_LIMIT
) {
o.push({
label: 'revoke',
desc: '撤回',
icon: markRaw(RevokeIcon),
index: 4
})
}
return o.sort((a, b) => a.index - b.index)
})
const containerRef = ref()
const menuRef = ref()
const isShowMenu = ref(false)
const x = ref(0)
const y = ref(0)
onMounted(() => {
containerRef.value?.addEventListener('contextmenu', handleShowMenu)
document.addEventListener('keydown', handleEscEvent)
document.addEventListener('click', closeMenu) //在其他地方的click事件要能关闭菜单
document.addEventListener('contextmenu', closeMenu) //在其他地方的菜单事件也要能关闭菜单
})
onUnmounted(() => {
containerRef.value?.removeEventListener('contextmenu', handleShowMenu)
document.removeEventListener('keydown', handleEscEvent)
document.removeEventListener('click', closeMenu)
document.removeEventListener('contextmenu', closeMenu)
})
// 监听菜单状态变化
watch(
() => menuData.activeMenu,
(newVal) => {
if (newVal !== menuName.value && isShowMenu.value) {
closeMenu()
}
}
)
const handleShowMenu = (e) => {
e.preventDefault() //阻止浏览器默认行为
e.stopPropagation() // 阻止冒泡
isShowMenu.value = true
menuData.setActiveMenu(menuName.value)
openMenuTime.value = new Date()
nextTick(() => {
//如果发现菜单超出window.innerWidth屏幕宽度x要修正一下往左边弹出菜单
if (e.clientX + menuRef.value.clientWidth > window.innerWidth) {
x.value = e.clientX - menuRef.value.clientWidth
} else {
x.value = e.clientX
}
// 如果发现菜单超出window.innerHeight屏幕高度y要修正一下往上面弹出菜单
if (e.clientY + menuRef.value.clientHeight > window.innerHeight) {
y.value = e.clientY - menuRef.value.clientHeight
} else {
y.value = e.clientY
}
})
}
const handleEscEvent = (event) => {
if (event.key === 'Escape') isShowMenu.value = false
}
const closeMenu = () => {
isShowMenu.value = false
openMenuTime.value = null
}
const handleClick = (item) => {
emit('selectMenu', item.label)
}
</script>
<template>
<div class="context-menu-container" ref="containerRef">
<!-- 在定义的插槽范围内都能打开菜单超出了就不行 -->
<slot></slot>
<Teleport to="body">
<div
v-if="isShowMenu"
class="context-menu"
:style="{ left: x + 'px', top: y + 'px' }"
@contextmenu.prevent
ref="menuRef"
>
<div class="menu-list">
<div class="menu-item" v-for="item in menu" :key="item.label" @click="handleClick(item)">
<component class="menu-icon" :is="item.icon" />
<span class="menu-desc text-ellipsis">{{ item.desc }}</span>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style lang="scss" scoped>
.context-menu {
padding: 5px;
border-radius: 6px;
background-color: #fff;
position: absolute;
box-shadow: 2px 2px 20px gray;
z-index: 1000;
.menu-item {
padding: 5px;
margin-top: 3px;
border-radius: 4px;
display: flex;
cursor: pointer;
&:hover {
background-color: #e6e8eb;
}
.menu-icon {
width: 20px;
height: 20px;
}
.menu-desc {
padding-left: 5px;
padding-right: 5px;
display: flex;
justify-content: start;
align-items: center;
font-size: 14px;
}
}
}
</style>

View File

@@ -0,0 +1,142 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useMenuStore } from '@/stores'
const menuData = useMenuStore()
const menuName = 'MenuMsgMain' // 菜单唯一标识
const emit = defineEmits(['selectMenu'])
const menu = computed(() => {
return [
{
label: 'clearScreen',
desc: '清屏'
}
]
})
const containerRef = ref()
const menuRef = ref()
const isShowMenu = ref(false)
const x = ref(0)
const y = ref(0)
onMounted(() => {
containerRef.value?.addEventListener('contextmenu', handleShowMenu)
document.addEventListener('keydown', handleEscEvent)
document.addEventListener('click', closeMenu) //在其他地方的click事件要能关闭菜单
document.addEventListener('contextmenu', closeMenu) //在其他地方的菜单事件也要能关闭菜单
})
onUnmounted(() => {
containerRef.value?.removeEventListener('contextmenu', handleShowMenu)
document.removeEventListener('keydown', handleEscEvent)
document.removeEventListener('click', closeMenu)
document.removeEventListener('contextmenu', closeMenu)
})
// 监听菜单状态变化
watch(
() => menuData.activeMenu,
(newVal) => {
if (newVal !== menuName && isShowMenu.value) {
closeMenu()
}
}
)
const handleShowMenu = (e) => {
e.preventDefault() //阻止浏览器默认行为
e.stopPropagation() // 阻止冒泡
isShowMenu.value = true
menuData.setActiveMenu(menuName)
nextTick(() => {
//如果发现菜单超出window.innerWidth屏幕宽度x要修正一下往左边弹出菜单
if (e.clientX + menuRef.value.clientWidth > window.innerWidth) {
x.value = e.clientX - menuRef.value.clientWidth
} else {
x.value = e.clientX
}
// 如果发现菜单超出window.innerHeight屏幕高度y要修正一下往上面弹出菜单
if (e.clientY + menuRef.value.clientHeight > window.innerHeight) {
y.value = e.clientY - menuRef.value.clientHeight
} else {
y.value = e.clientY
}
})
}
const handleEscEvent = (event) => {
if (event.key === 'Escape') isShowMenu.value = false
}
const closeMenu = () => {
isShowMenu.value = false
}
const handleClick = (item) => {
emit('selectMenu', item.label)
}
</script>
<template>
<div class="context-menu-container" ref="containerRef">
<!-- 在定义的插槽范围内都能打开菜单超出了就不行 -->
<slot></slot>
<Teleport to="body">
<div
v-if="isShowMenu"
class="context-menu"
:style="{ left: x + 'px', top: y + 'px' }"
@contextmenu.prevent
ref="menuRef"
>
<div class="menu-list">
<div class="menu-item" v-for="item in menu" :key="item.label" @click="handleClick(item)">
<component class="menu-icon" :is="item.icon" />
<span class="menu-desc text-ellipsis">{{ item.desc }}</span>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style lang="scss" scoped>
.context-menu {
padding: 5px;
border-radius: 6px;
background-color: #fff;
position: absolute;
box-shadow: 2px 2px 20px gray;
z-index: 1000;
.menu-item {
padding: 5px;
margin-top: 3px;
border-radius: 4px;
display: flex;
cursor: pointer;
&:hover {
background-color: #e6e8eb;
}
.menu-icon {
width: 20px;
height: 20px;
}
.menu-desc {
padding-left: 5px;
padding-right: 5px;
display: flex;
justify-content: start;
align-items: center;
font-size: 14px;
}
}
}
</style>

View File

@@ -1,12 +1,14 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Top, Bottom, MuteNotification, Bell, CircleClose, Edit } from '@element-plus/icons-vue'
import { useMessageStore } from '@/stores'
import { useMessageStore, useMenuStore } from '@/stores'
const props = defineProps(['sessionId'])
const emit = defineEmits(['selectMenu', 'closeMenu'])
const messageData = useMessageStore()
const menuData = useMenuStore()
const menuName = 'MenuSession' //
const top = computed(() => {
if (props.sessionId) {
@@ -56,25 +58,36 @@ const x = ref(0)
const y = ref(0)
onMounted(() => {
containerRef.value?.addEventListener('contextmenu', handleSessionMenu)
containerRef.value?.addEventListener('contextmenu', handleShowMenu)
document.addEventListener('keydown', handleEscEvent)
document.addEventListener('click', closeMenu) //click
document.addEventListener('contextmenu', closeMenu) //
})
onUnmounted(() => {
containerRef.value?.removeEventListener('contextmenu', handleSessionMenu)
containerRef.value?.removeEventListener('contextmenu', handleShowMenu)
document.removeEventListener('keydown', handleEscEvent)
document.removeEventListener('click', closeMenu)
document.removeEventListener('contextmenu', closeMenu)
})
const handleSessionMenu = (e) => {
//
watch(
() => menuData.activeMenu,
(newVal) => {
if (newVal !== menuName && isShowMenu.value) {
closeMenu()
}
}
)
const handleShowMenu = (e) => {
isShowMenu.value = props.sessionId && true
if (!isShowMenu.value) {
return
}
menuData.setActiveMenu(menuName)
e.preventDefault() //
e.stopPropagation() //
x.value = e.clientX
@@ -134,6 +147,7 @@ const handleClick = (item) => {
background-color: #fff;
position: fixed;
box-shadow: 2px 2px 20px gray;
z-index: 1000;
.menu-item {
padding: 5px;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import AudioFileIcon from '@/assets/svg/audiofile.svg'
import { formatFileSize } from '@/js/utils/common'
import { ElMessage } from 'element-plus'
import { CircleCheckFilled, WarningFilled } from '@element-plus/icons-vue'
const props = defineProps(['url', 'fileName', 'size'])
const emits = defineEmits(['load'])
const formatSize = computed(() => {
return formatFileSize(props.size)
})
onMounted(() => {
emits('load') //向父组件暴露load事件
})
const isDownloading = ref(false)
const isDownloadComplete = ref(false)
const isDownloadError = ref(false)
const progress = ref(0)
const onDownload = async () => {
isDownloading.value = true
isDownloadComplete.value = false
isDownloadError.value = false
progress.value = 0
try {
const response = await fetch(props.url)
if (!response.ok) {
ElMessage.error('文件资源异常,请稍后再试。')
isDownloading.value = false
isDownloadError.value = true
return
}
const contentLength = response.headers.get('content-length')
const total = contentLength ? parseInt(contentLength, 10) : 0
let loaded = 0
const reader = response.body.getReader()
const chunks = []
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
loaded += value.length
progress.value = total > 0 ? (loaded / total) * 100 : 0
}
const blob = new Blob(chunks)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.fileName
a.click()
URL.revokeObjectURL(url)
isDownloading.value = false
isDownloadComplete.value = true
} catch (error) {
ElMessage.error('下载文件时出错,请稍后再试。')
isDownloading.value = false
isDownloadError.value = true
}
}
</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>
<div v-if="props.url" class="download">
<span
v-if="!isDownloading && !isDownloadComplete && !isDownloadError"
@click="onDownload"
style="cursor: pointer"
>
下载
</span>
<div
v-else-if="isDownloading"
class="loading-ring"
:style="{ '--progress': progress + '%' }"
>
<div class="progress"></div>
</div>
<div
v-else-if="!isDownloading && isDownloadComplete"
class="check-success"
title="已下载"
>
<CircleCheckFilled />
</div>
<div
v-else-if="!isDownloading && isDownloadError"
class="check-fail"
title="点击重试"
@click="onDownload"
>
<WarningFilled />
</div>
</div>
</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;
.download {
width: 32px;
color: blue;
display: flex;
justify-content: center;
}
.loading-ring {
position: relative;
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-block;
vertical-align: middle;
.progress {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background-color: white;
border-radius: 50%;
}
}
.loading-ring:not(.completed) {
background: conic-gradient(#007bff 0% var(--progress), #ccc var(--progress) 100%);
}
.loading-ring.completed {
background: #007bff;
}
.check-success {
width: 18px;
height: 18px;
color: #95d475;
}
.check-fail {
width: 18px;
height: 18px;
color: red;
cursor: pointer;
}
}
}
}
</style>

View File

@@ -0,0 +1,253 @@
<script setup>
import { ref, 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'
import AudioFileIcon from '@/assets/svg/audiofile.svg'
import VideoFileIcon from '@/assets/svg/videofile.svg'
import { CircleCheckFilled, WarningFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps(['url', 'contentType', 'fileName', 'fileSize', 'use'])
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,
4: AudioFileIcon,
6: VideoFileIcon,
7: ArchiveIcon
}
const iconComponent = computed(() => {
return iconMap[props.contentType] || DocumentIcon
})
const formatSize = computed(() => {
return formatFileSize(props.fileSize)
})
const mainStyle = computed(() => {
if (props.use && props.use === 'agree') {
return { width: 'auto', maxWidth: '240px' }
} else {
return { width: '140px' }
}
})
const isDownloading = ref(false)
const isDownloadComplete = ref(false)
const isDownloadError = ref(false)
const progress = ref(0)
onMounted(() => {
emits('load') //向父组件暴露load事件
})
const onDownload = async () => {
isDownloading.value = true
isDownloadComplete.value = false
isDownloadError.value = false
progress.value = 0
try {
const response = await fetch(props.url)
if (!response.ok) {
ElMessage.error('文件资源异常,请稍后再试。')
isDownloading.value = false
isDownloadError.value = true
return
}
const contentLength = response.headers.get('content-length')
const total = contentLength ? parseInt(contentLength, 10) : 0
let loaded = 0
const reader = response.body.getReader()
const chunks = []
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
loaded += value.length
progress.value = total > 0 ? (loaded / total) * 100 : 0
}
const blob = new Blob(chunks)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.fileName
a.click()
URL.revokeObjectURL(url)
isDownloading.value = false
isDownloadComplete.value = true
} catch (error) {
ElMessage.error('下载文件时出错,请稍后再试。')
isDownloading.value = false
isDownloadError.value = true
}
}
</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" :style="mainStyle">
<span class="file-name text-ellipsis" :title="props.fileName || '未知'">
{{ props.fileName || '未知' }}
</span>
<div class="footer">
<div class="size" :title="formatSize">{{ formatSize }}</div>
<div v-if="props.url" class="download">
<span
v-if="!isDownloading && !isDownloadComplete && !isDownloadError"
@click="onDownload"
style="cursor: pointer"
>
下载
</span>
<div
v-else-if="isDownloading"
class="loading-ring"
:style="{ '--progress': progress + '%' }"
>
<div class="progress"></div>
</div>
<div
v-else-if="!isDownloading && isDownloadComplete"
class="check-success"
title="已下载"
>
<CircleCheckFilled />
</div>
<div
v-else-if="!isDownloading && isDownloadError"
class="check-fail"
title="点击重试"
@click="onDownload"
>
<WarningFilled />
</div>
</div>
</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 {
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;
.download {
width: 32px;
color: blue;
display: flex;
justify-content: center;
}
.loading-ring {
position: relative;
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-block;
vertical-align: middle;
.progress {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background-color: white;
border-radius: 50%;
}
}
.loading-ring:not(.completed) {
background: conic-gradient(#007bff 0% var(--progress), #ccc var(--progress) 100%);
}
.loading-ring.completed {
background: #007bff;
}
.check-success {
width: 18px;
height: 18px;
color: #95d475;
}
.check-fail {
width: 18px;
height: 18px;
color: red;
cursor: pointer;
}
}
}
}
</style>

View File

@@ -0,0 +1,195 @@
<script setup>
import { computed } from 'vue'
import { ElImage } from 'element-plus'
import { formatFileSize } from '@/js/utils/common'
import { useImageStore } from '@/stores'
import ImageloadfailedIcon from '@/assets/svg/imageloadfailed.svg'
const props = defineProps(['sessionId', 'imgId', 'isScreenShot', 'thumbWidth', 'thumbHeight'])
const emits = defineEmits(['load'])
const imageData = useImageStore()
const maxWidth = computed(() => {
return props.isScreenShot ? Math.min(props.thumbWidth, 360) : 360
})
const maxHeight = computed(() => {
return props.isScreenShot ? Math.min(props.thumbHeight, 270) : 270
})
const renderWidth = computed(() => {
if (!props.thumbWidth || !props.thumbHeight) {
return 360 // 如果拿不到缩略图大小,默认以 360*270 尺寸显示
} else if (props.thumbWidth / props.thumbHeight > maxWidth.value / maxHeight.value) {
return maxWidth.value
} else {
return (props.thumbWidth / props.thumbHeight) * maxHeight.value
}
})
const renderHeight = computed(() => {
if (!props.thumbWidth || !props.thumbHeight) {
return 270 // 如果拿不到缩略图大小,默认以 360*270 尺寸显示
} else if (props.thumbWidth / props.thumbHeight > maxWidth.value / maxHeight.value) {
return (props.thumbHeight / props.thumbWidth) * maxWidth.value
} else {
return maxHeight.value
}
})
const onLoad = async () => {
emits('load') //向父组件暴露load事件
}
const url = computed(() => {
return imageData.image[props.imgId]?.thumbUrl
})
const imageInSessionSort = computed(() => {
const imageList = Object.values(imageData.imageInSession[props.sessionId])
return imageList.sort((a, b) => {
const bTime = new Date(b.createdTime).getTime()
const aTime = new Date(a.createdTime).getTime()
return aTime - bTime
})
})
const srcList = computed(() => {
return imageInSessionSort.value.map((item) => item.originUrl)
})
const initialIndex = computed(() => {
const imgIdList = imageInSessionSort.value.map((item) => item.objectId.toString())
return imgIdList.indexOf(props.imgId.toString())
})
const fileName = computed(() => {
return props.isScreenShot ? '' : imageData.image[props.imgId]?.fileName
})
const size = computed(() => {
return props.isScreenShot ? '' : imageData.image[props.imgId]?.size
})
const formatSize = computed(() => {
return formatFileSize(size.value)
})
</script>
<template>
<div class="image-msg-wrapper">
<el-image
:src="url"
:alt="props.imgId"
:preview-src-list="srcList"
hide-on-click-modal
preview-teleported
:initial-index="initialIndex"
:infinite="false"
:lazy="false"
fit="contain"
:style="{ width: `${renderWidth}px`, height: `${renderHeight}px` }"
@load="onLoad"
>
<template #placeholder>
<div
class="image-msg-bgc loading"
:style="{ width: `${renderWidth}px`, height: `${renderHeight}px` }"
></div>
</template>
<template #error>
<div
class="image-msg-bgc error"
:style="{ width: `${renderWidth}px`, height: `${renderHeight}px` }"
>
<ImageloadfailedIcon style="width: 48px; height: 48px; fill: #fff" />
</div>
</template>
</el-image>
<div v-if="fileName || size > 0" class="info">
<span class="name item text-ellipsis" :title="fileName">
{{ 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 {
width: auto;
height: auto;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
: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;
}
}
}
.image-msg-bgc {
background-color: #000;
}
.image-msg-bgc.loading::before {
content: '';
box-sizing: border-box;
position: absolute;
top: 50%;
left: 50%;
width: 30px;
height: 30px;
margin-top: -15px;
margin-left: -15px;
border-radius: 50%;
border: 3px solid #ccc;
border-top-color: #007bff;
animation: spin 1s ease-in-out infinite;
}
.image-msg-bgc.error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted } 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'
import { showDurationFormat } from '@/js/utils/common'
const props = defineProps(['audioUrl', 'duration'])
const emits = defineEmits(['load'])
@@ -12,17 +13,6 @@ 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) {
@@ -98,7 +88,7 @@ onMounted(() => {
:playtime-slider-color="`#409eff`"
></AVWaveform>
<span class="time">{{ formatDuration }}</span>
<span class="time">{{ showDurationFormat(audioDuration) }}</span>
</div>
</template>

View File

@@ -0,0 +1,165 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import Player from 'xgplayer'
import 'xgplayer/dist/index.min.css'
import { formatFileSize } from '@/js/utils/common'
import VideoloadfailedIcon from '@/assets/svg/videoloadfailed.svg'
const props = defineProps(['msgId', 'videoId', 'url', 'fileName', 'size', 'width', 'height'])
const emits = defineEmits(['load'])
const isLoaded = ref(0) // 0未加载1加载成功2加载失败
const isClickPlay = ref(false)
const videoWrapperRef = ref(null)
const formatSize = computed(() => {
return formatFileSize(props.size)
})
const renderWidth = computed(() => {
if (!props.width || !props.height) {
return 480 // 如果拿不到视频大小,默认以 480*270 尺寸播放
} else if (props.width > props.height) {
return 480
} else {
return (props.width / props.height) * 320
}
})
const renderHeight = computed(() => {
if (!props.width || !props.height) {
return 270 // 如果拿不到视频大小,默认以 480*270 尺寸播放
} else if (props.width > props.height) {
return (props.height / props.width) * 480
} else {
return 320
}
})
onMounted(() => {
const player = new Player({
id: `msg-xgplayer-${props.msgId}-${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', () => {
videoWrapperRef.value.style.width = `${renderWidth.value}px`
videoWrapperRef.value.style.height = `${renderHeight.value}px`
videoWrapperRef.value.style.padding = 0
isLoaded.value = 1
emits('load') //向父组件暴露load事件
})
})
// 监听视频加载失败事件
player.on('error', () => {
isLoaded.value = 2
})
})
</script>
<template>
<div
class="video-msg-wrapper"
:class="{ loading: isLoaded === 0 }"
:style="{ width: `${renderWidth}px`, height: `${renderHeight}px` }"
>
<div
v-show="isLoaded === 1"
ref="videoWrapperRef"
:id="`msg-xgplayer-${props.msgId}-${props.videoId}`"
></div>
<div v-show="isLoaded === 2" class="error">
<VideoloadfailedIcon style="width: 48px; height: 48px; fill: #fff" />
<span style="color: #fff">视频加载失败</span>
</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;
background: #000;
display: flex;
justify-content: center;
align-items: center;
.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;
}
}
.error {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
}
.video-msg-wrapper.loading::before {
content: '';
box-sizing: border-box;
position: absolute;
top: 50%;
left: 50%;
width: 30px;
height: 30px;
margin-top: -15px;
margin-left: -15px;
border-radius: 50%;
border: 3px solid #ccc;
border-top-color: #007bff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch, reactive } from 'vue'
import { ref, computed, watch } from 'vue'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import GroupAvatarIcon from '@/components/common/GroupAvatarIcon.vue'
import SessionTag from './SessionTag.vue'
@@ -10,7 +10,8 @@ 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'
import { msgSendStatus } from '@/const/msgConst'
import { showSimplifyMsgContent } from '@/js/utils/message'
const props = defineProps([
'sessionId',
@@ -82,11 +83,11 @@ const isNotInGroup = computed(() => {
})
const lastMsg = computed(() => {
const msgIds = messageData.msgIdSortArray[props.sessionId]
if (!msgIds?.length) {
return reactive({})
const msgKeyS = messageData.msgKeySortedArray[props.sessionId]
if (!msgKeyS?.length) {
return ref({})
}
return reactive({ ...messageData.getMsg(props.sessionId, msgIds[msgIds.length - 1]) })
return messageData.getMsg(props.sessionId, msgKeyS[msgKeyS.length - 1])
})
const lastMsgId = computed(() => {
@@ -217,78 +218,56 @@ const getGroupChatMsgTips = (content) => {
const showDetailContent = computed(() => {
if (isShowDraft.value) {
return sessionInfo.value.draft?.replace(/\{\d+\}/g, '[图片]') // 把内容中的`{xxxxxx}`格式的图片统一转成`[图片]`
return showSimplifyMsgContent(sessionInfo.value.draft)
} else {
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 {
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)
const jsonContent = jsonParseSafe(lastMsg.value.content)
switch (lastMsg.value.msgType) {
case MsgType.SYS_GROUP_CREATE:
return getSysGroupCreateMsgTips(content)
return getSysGroupCreateMsgTips(jsonContent)
case MsgType.SYS_GROUP_ADD_MEMBER:
return getSysGroupAddMemberMsgTips(content)
return getSysGroupAddMemberMsgTips(jsonContent)
case MsgType.SYS_GROUP_DEL_MEMBER:
return getSysGroupDelMemberMsgTips(content)
return getSysGroupDelMemberMsgTips(jsonContent)
case MsgType.SYS_GROUP_UPDATE_ANNOUNCEMENT:
return getSysGroupUpdateAnnouncement(content)
return getSysGroupUpdateAnnouncement(jsonContent)
case MsgType.SYS_GROUP_UPDATE_NAME:
return getSysGroupUpdateName(content)
return getSysGroupUpdateName(jsonContent)
case MsgType.SYS_GROUP_UPDATE_AVATAR:
return getSysGroupUpdateAvatar(content)
return getSysGroupUpdateAvatar(jsonContent)
case MsgType.SYS_GROUP_SET_ADMIN:
case MsgType.SYS_GROUP_CANCEL_ADMIN:
return getSysGroupChangeRoleMsgTips(lastMsg.value.msgType, content)
return getSysGroupChangeRoleMsgTips(lastMsg.value.msgType, jsonContent)
case MsgType.SYS_GROUP_SET_ALL_MUTED:
case MsgType.SYS_GROUP_CANCEL_ALL_MUTED:
return getSysGroupUpdateAllMuted(lastMsg.value.msgType, content)
return getSysGroupUpdateAllMuted(lastMsg.value.msgType, jsonContent)
case MsgType.SYS_GROUP_SET_JOIN_APPROVAL:
case MsgType.SYS_GROUP_CANCEL_JOIN_APPROVAL:
return getSysGroupUpdateJoinApproval(lastMsg.value.msgType, content)
return getSysGroupUpdateJoinApproval(lastMsg.value.msgType, jsonContent)
case MsgType.SYS_GROUP_SET_HISTORY_BROWSE:
case MsgType.SYS_GROUP_CANCEL_HISTORY_BROWSE:
return getSysGroupUpdateHistoryBrowse(lastMsg.value.msgType, content)
return getSysGroupUpdateHistoryBrowse(lastMsg.value.msgType, jsonContent)
case MsgType.SYS_GROUP_OWNER_TRANSFER:
return getSysGroupOwnerTransfer(content)
return getSysGroupOwnerTransfer(jsonContent)
case MsgType.SYS_GROUP_UPDATE_MEMBER_MUTED:
return getSysGroupUpdateMemberMuted(content)
return getSysGroupUpdateMemberMuted(jsonContent)
case MsgType.SYS_GROUP_LEAVE:
return getSysGroupLeave(content)
return getSysGroupLeave(jsonContent)
case MsgType.SYS_GROUP_DROP:
return getSysGroupDrop(content)
return getSysGroupDrop(jsonContent)
case MsgType.GROUP_CHAT:
return getGroupChatMsgTips(lastMsg.value.content.replace(/\{\d+\}/g, '[图片]'))
return getGroupChatMsgTips(showSimplifyMsgContent(lastMsg.value.content))
default:
return ''
return '...'
}
} else if (sessionInfo.value.sessionType === MsgType.CHAT) {
return showSimplifyMsgContent(lastMsg.value.content)
} else {
return lastMsg.value.content.replace(/\{\d+\}/g, '[图片]')
return '...'
}
}
})
@@ -297,6 +276,15 @@ const isShowDraft = computed(() => {
return !hasBeenSelected.value && sessionInfo.value.draft
})
const isShowAt = computed(() => {
const atRecords = messageData.atRecordsList[props.sessionId]
if (sessionInfo.value.sessionType === MsgType.GROUP_CHAT && atRecords) {
return atRecords.some((item) => item.referMsgId > sessionInfo.value?.readMsgId)
}
return false
})
const isShowUnread = computed(() => {
if (
sessionInfo.value.sessionType === MsgType.CHAT &&
@@ -464,13 +452,14 @@ defineExpose({
</div>
<div class="body">
<div class="content">
<span v-if="isShowAt" class="at-tips">[有人提到了你]</span>
<span v-if="isShowUnreadCount" class="unread-count"
>[{{ sessionInfo.unreadCount > 99 ? '99+' : sessionInfo.unreadCount }}]</span
>[{{ sessionInfo.unreadCount > 99 ? '99+' : sessionInfo.unreadCount }}未读]</span
>
<span v-if="isShowDraft" class="draft">[草稿]</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 v-else-if="isShowUnSend" class="unread-or-read">[发送失败]</span>
<span class="detail text-ellipsis"> {{ showDetailContent }}</span>
</div>
<div class="action">
@@ -597,6 +586,11 @@ defineExpose({
flex-shrink: 0;
}
.at-tips {
color: red;
flex-shrink: 0;
}
.unread-or-read {
color: gray;
flex-shrink: 0;

View File

@@ -1,112 +0,0 @@
<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

@@ -26,6 +26,7 @@ onMounted(async () => {
const onNewAvatar = ({ avatarId, avatar }) => {
formModel.value.avatarId = avatarId
avatarUrl.value = avatar
onSave()
}
const onSave = () => {

View File

@@ -2,11 +2,19 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import svgLoader from 'vite-svg-loader'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), svgLoader()],
plugins: [
vue(),
vueJsx({
transformOn: true,
optimize: true
}),
svgLoader()
],
base: '/im',
resolve: {
alias: {