Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33fe4936b4 | ||
|
|
70afd3ae7e | ||
|
|
048adef4ea | ||
|
|
44ff5b0134 | ||
|
|
01195ffebe | ||
|
|
0a12d37778 | ||
|
|
a011501089 | ||
|
|
ddbf6614e7 | ||
|
|
16b46fe6f8 | ||
|
|
890c1f9fd6 | ||
|
|
88bc4f3efb | ||
|
|
5c0b255092 | ||
|
|
e3d01fc035 | ||
|
|
9880f813f8 | ||
|
|
a2017be96a | ||
|
|
ecfa623a72 | ||
|
|
e63ac508f3 | ||
|
|
ad0d0ad7a0 | ||
|
|
653bbccb24 | ||
|
|
748423b2b9 | ||
|
|
5ac2d69c43 | ||
|
|
da93d05d67 | ||
|
|
83376a2a6a | ||
|
|
fd5a645bba | ||
|
|
7618b68713 | ||
|
|
b8dd1a8d04 | ||
|
|
9db1dc2c3a | ||
|
|
76dfcecf2b | ||
|
|
673dcd044b | ||
|
|
ea8dabb9d8 | ||
|
|
edf5fe4418 | ||
|
|
93a66e760f | ||
|
|
d9cc186fc1 | ||
|
|
92e73fe094 | ||
|
|
b1039b9c6f | ||
|
|
e187025d4e | ||
|
|
6673804208 | ||
|
|
0f2167015e | ||
|
|
75d37d0ceb | ||
|
|
94bfe0e957 | ||
|
|
98b66780c3 | ||
|
|
5763438082 | ||
|
|
6e7e72d92d | ||
|
|
84b113eb44 | ||
|
|
958be10181 | ||
|
|
e357429d3d | ||
|
|
240b0560dc | ||
|
|
58e46fa09f | ||
|
|
f88b6a0388 | ||
|
|
ea1e74fd08 | ||
|
|
41947e9060 | ||
|
|
0fd2a4d4cb | ||
|
|
81fdfda734 | ||
|
|
f8a949b4a8 | ||
|
|
bfecd911fe | ||
|
|
e500498b9e | ||
|
|
4af374f703 | ||
|
|
90b2144c6e | ||
|
|
9c8c4d905b | ||
|
|
9ff8e82eb9 | ||
|
|
b0d085b36c |
@@ -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 中使用的变量被正确识别
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
"trailingComma": "none",
|
||||
"jsxBracketSameLine": true
|
||||
}
|
||||
|
||||
16
README.md
@@ -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
|
||||
|
||||
## 如何联系我们
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 121 KiB |
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "anylink-web",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -35,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",
|
||||
|
||||
@@ -12,6 +12,18 @@ 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')
|
||||
}
|
||||
@@ -24,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)
|
||||
}
|
||||
|
||||
1
src/assets/svg/cancle.svg
Normal 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
@@ -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 |
1
src/assets/svg/deletemsg.svg
Normal 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 |
|
Before Width: | Height: | Size: 972 B After Width: | Height: | Size: 972 B |
1
src/assets/svg/forward.svg
Normal 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 |
1
src/assets/svg/forwardobo.svg
Normal 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 |
1
src/assets/svg/multiselect.svg
Normal 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
@@ -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 |
1
src/assets/svg/revoke.svg
Normal 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 |
1
src/assets/svg/videofile.svg
Normal 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 |
@@ -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'"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
305
src/components/common/SelectSessionDialog.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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'])
|
||||
|
||||
|
||||
141
src/components/item/SessionTitleItem.vue
Normal 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>
|
||||
@@ -114,7 +114,7 @@ watch(searchTab, () => {
|
||||
v-model="isShowSearchDialog"
|
||||
:show-close="false"
|
||||
:modal="false"
|
||||
:z-index="1"
|
||||
:z-index="1000"
|
||||
@open="onOpen"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -11,16 +11,23 @@ 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 // 合并转发消息
|
||||
}
|
||||
|
||||
// 消息发送状态
|
||||
@@ -41,3 +48,13 @@ export const msgFileUploadStatus = {
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const CLIENT_TYPE = 2
|
||||
export const CLIENT_NAME = 'web'
|
||||
export const CLIENT_VERSION = '1.3.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
|
||||
|
||||
@@ -5,3 +5,5 @@ export * from './receiveGroupChatMsg'
|
||||
export * from './receiveGroupChatReadMsg'
|
||||
export * from './receiveGroupSystemMsg'
|
||||
export * from './receiveAtMsg'
|
||||
export * from './receiveRevokeMsg'
|
||||
export * from './receiveDeleteMsg'
|
||||
|
||||
13
src/js/event/receiveDeleteMsg.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ export const onReceiveGroupSystemMsg = (updateScroll, capacity) => {
|
||||
content: msg.body.content,
|
||||
msgTime: now
|
||||
}
|
||||
await messageData.preloadResource([showMsg])
|
||||
messageData.addMsgRecords(sessionId, [showMsg])
|
||||
messageData.updateMsgKeySort(sessionId)
|
||||
|
||||
|
||||
10
src/js/event/receiveRevokeMsg.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -164,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
|
||||
@@ -242,12 +252,42 @@ export const formatFileSize = (size) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多功能匹配:忽略大小写,字符匹配,拼音匹配,拼音缩写匹配
|
||||
* @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
|
||||
*/
|
||||
export const getFullPinyin = (name) => {
|
||||
const getFullPinyin = (name) => {
|
||||
return pinyin(name, { toneType: 'none', type: 'string' }).replaceAll(' ', '').toLowerCase()
|
||||
}
|
||||
|
||||
@@ -256,7 +296,7 @@ export const getFullPinyin = (name) => {
|
||||
* @param {*} name
|
||||
* @returns
|
||||
*/
|
||||
export const getInitialsPinyin = (name) => {
|
||||
const getInitialsPinyin = (name) => {
|
||||
return pinyin(name, {
|
||||
pattern: 'first',
|
||||
toneType: 'none',
|
||||
|
||||
217
src/js/utils/message.js
Normal 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
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { proto } from '@/const/msgConst'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export const chatConstructor = ({ sessionId, remoteId, content, sequence }) => {
|
||||
export const chatConstructor = ({ sessionId, remoteId, content, contentType, sequence }) => {
|
||||
const header = Header.create({
|
||||
magic: proto.magic,
|
||||
version: proto.version,
|
||||
@@ -18,6 +18,7 @@ export const chatConstructor = ({ sessionId, remoteId, content, sequence }) => {
|
||||
toId: remoteId,
|
||||
sessionId: sessionId,
|
||||
content: content,
|
||||
contentType: contentType,
|
||||
seq: sequence
|
||||
})
|
||||
const chatMsg = Msg.create({ header: header, body: body })
|
||||
@@ -27,7 +28,7 @@ export const chatConstructor = ({ sessionId, remoteId, content, sequence }) => {
|
||||
return data
|
||||
}
|
||||
|
||||
export const groupChatConstructor = ({ sessionId, remoteId, content, sequence }) => {
|
||||
export const groupChatConstructor = ({ sessionId, remoteId, content, contentType, sequence }) => {
|
||||
const header = Header.create({
|
||||
magic: proto.magic,
|
||||
version: proto.version,
|
||||
@@ -42,6 +43,7 @@ export const groupChatConstructor = ({ sessionId, remoteId, content, sequence })
|
||||
sessionId: sessionId,
|
||||
groupId: remoteId,
|
||||
content: content,
|
||||
contentType: contentType,
|
||||
seq: sequence
|
||||
})
|
||||
const msg = Msg.create({ header: header, body: body })
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
onReceiveGroupChatMsg,
|
||||
onReceiveGroupChatReadMsg,
|
||||
onReceiveGroupSystemMsg,
|
||||
onReceiveAtMsg
|
||||
onReceiveAtMsg,
|
||||
onReceiveRevokeMsg,
|
||||
onReceiveDeleteMsg
|
||||
} from '@/js/event'
|
||||
|
||||
class WsConnect {
|
||||
@@ -121,6 +123,8 @@ class WsConnect {
|
||||
[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(),
|
||||
@@ -339,9 +343,15 @@ 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 })
|
||||
const data = this.dataConstructor[msgType]({
|
||||
sessionId,
|
||||
remoteId,
|
||||
content,
|
||||
contentType,
|
||||
sequence
|
||||
})
|
||||
before(data)
|
||||
this.msgIdRefillCallback[sequence] = after
|
||||
this.sendAgent(data)
|
||||
|
||||
@@ -64,6 +64,7 @@ class Message {
|
||||
fromId,
|
||||
msgType,
|
||||
content,
|
||||
contentType,
|
||||
msgTime,
|
||||
sendTime = undefined,
|
||||
msgId = undefined,
|
||||
@@ -78,6 +79,7 @@ class Message {
|
||||
this.fromId = fromId
|
||||
this.msgType = msgType
|
||||
this.content = content
|
||||
this.contentType = contentType
|
||||
this.status = status
|
||||
this.msgTime = msgTime
|
||||
this.sendTime = sendTime
|
||||
|
||||
@@ -294,6 +294,8 @@ export const Msg = ($root.Msg = (() => {
|
||||
* @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
|
||||
@@ -331,6 +333,8 @@ export const MsgType = ($root.MsgType = (() => {
|
||||
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
|
||||
@@ -546,6 +550,8 @@ export const Header = ($root.Header = (() => {
|
||||
case 9:
|
||||
case 10:
|
||||
case 11:
|
||||
case 12:
|
||||
case 13:
|
||||
case 21:
|
||||
case 22:
|
||||
case 23:
|
||||
@@ -642,6 +648,14 @@ export const Header = ($root.Header = (() => {
|
||||
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
|
||||
@@ -805,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
|
||||
*/
|
||||
@@ -822,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 AT(up) AT(down)
|
||||
* +---+--------------+------------+------------+-------------+------------+---------+---------+
|
||||
* | 1 | fromId | M | M | M | - | M | M |
|
||||
* | 2 | fromClient | M | M | M | - | M | M |
|
||||
* | 3 | toId | - | - | - | - | - | M |
|
||||
* | 4 | toClient | - | - | - | - | - | M |
|
||||
* | 5 | groupId | - | - | - | M | M | M |
|
||||
* | 6 | msgId | - | - | - | M | - | M |
|
||||
* | 7 | content | M | M | M | M | M | M |
|
||||
* | 8 | seq | - | - | - | - | M | M |
|
||||
* | 9 | sessionId | - | - | - | M | M | 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
|
||||
@@ -903,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
|
||||
@@ -964,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'])),
|
||||
@@ -1013,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
|
||||
}
|
||||
|
||||
@@ -1080,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
|
||||
}
|
||||
@@ -1157,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'
|
||||
@@ -1194,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
|
||||
@@ -1247,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'
|
||||
|
||||
@@ -19,6 +19,8 @@ enum MsgType {
|
||||
STATUS_RES = 9; //连接状态响应
|
||||
STATUS_SYNC = 10; //端侧的连接状态同步给云端(比如在线,离开)
|
||||
AT = 11; //@消息
|
||||
REVOKE = 12; //撤回消息
|
||||
DELETE = 13; //删除消息
|
||||
|
||||
SYS_GROUP_CREATE = 21; //系统消息之创建群组
|
||||
SYS_GROUP_ADD_MEMBER = 22; //系统消息之添加群组成员
|
||||
@@ -63,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 AT(up) AT(down)
|
||||
+---+--------------+------------+------------+-------------+------------+---------+---------+
|
||||
| 1 | fromId | M | M | M | - | M | M |
|
||||
| 2 | fromClient | M | M | M | - | M | M |
|
||||
| 3 | toId | - | - | - | - | - | M |
|
||||
| 4 | toClient | - | - | - | - | - | M |
|
||||
| 5 | groupId | - | - | - | M | M | M |
|
||||
| 6 | msgId | - | - | - | M | - | M |
|
||||
| 7 | content | M | M | M | M | M | M |
|
||||
| 8 | seq | - | - | - | - | M | M |
|
||||
| 9 | sessionId | - | - | - | M | M | 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;
|
||||
@@ -87,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 {
|
||||
|
||||
@@ -18,20 +18,30 @@ export const useAudioStore = defineStore('anylink-audio', () => {
|
||||
audio.value[obj.objectId] = obj
|
||||
}
|
||||
|
||||
const preloadAudio = async (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) {
|
||||
@@ -55,7 +65,9 @@ export const useAudioStore = defineStore('anylink-audio', () => {
|
||||
return {
|
||||
audio,
|
||||
setAudio,
|
||||
preloadAudio,
|
||||
localServerMap,
|
||||
setLocalServerMap,
|
||||
preloadAudioFromMsgList,
|
||||
clear
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,17 +18,30 @@ export const useDocumentStore = defineStore('anylink-document', () => {
|
||||
document.value[obj.objectId] = obj
|
||||
}
|
||||
|
||||
const preloadDocument = async (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) {
|
||||
@@ -52,7 +65,9 @@ export const useDocumentStore = defineStore('anylink-document', () => {
|
||||
return {
|
||||
document,
|
||||
setDocument,
|
||||
preloadDocument,
|
||||
localServerMap,
|
||||
setLocalServerMap,
|
||||
preloadDocumentFromMsgList,
|
||||
clear
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,7 +15,12 @@ export const useImageStore = defineStore('anylink-image', () => {
|
||||
const image = ref({})
|
||||
|
||||
/**
|
||||
* 在同一个session中的需要渲染的image对象数组
|
||||
* 在同一个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({})
|
||||
|
||||
@@ -27,30 +30,43 @@ export const useImageStore = defineStore('anylink-image', () => {
|
||||
|
||||
const setImageInSession = (sessionId, obj) => {
|
||||
if (!imageInSession.value[sessionId]) {
|
||||
imageInSession.value[sessionId] = []
|
||||
imageInSession.value[sessionId] = {}
|
||||
}
|
||||
imageInSession.value[sessionId].push(obj)
|
||||
imageInSession.value[sessionId][obj.objectId] = obj
|
||||
}
|
||||
|
||||
const clearImageInSession = (sessionId) => {
|
||||
if (imageInSession.value[sessionId]) {
|
||||
imageInSession.value[sessionId] = []
|
||||
imageInSession.value[sessionId] = {}
|
||||
}
|
||||
}
|
||||
|
||||
const loadImageInfoFromContent = async (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) => {
|
||||
@@ -59,29 +75,19 @@ export const useImageStore = defineStore('anylink-image', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const preloadImage = async (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) {
|
||||
@@ -109,11 +115,13 @@ export const useImageStore = defineStore('anylink-image', () => {
|
||||
return {
|
||||
image,
|
||||
imageInSession,
|
||||
localServerMap,
|
||||
setLocalServerMap,
|
||||
setImage,
|
||||
setImageInSession,
|
||||
clearImageInSession,
|
||||
loadImageInfoFromContent,
|
||||
preloadImage,
|
||||
preloadImageFromMsg,
|
||||
preloadImageFromMsgList,
|
||||
clear
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -134,10 +134,10 @@ export const useMessageStore = defineStore('anylink-message', () => {
|
||||
* @param {*} msgRecords
|
||||
*/
|
||||
const preloadResource = async (msgRecords) => {
|
||||
await useImageStore().preloadImage(msgRecords)
|
||||
await useAudioStore().preloadAudio(msgRecords)
|
||||
await useVideoStore().preloadVideo(msgRecords)
|
||||
await useDocumentStore().preloadDocument(msgRecords)
|
||||
await useImageStore().preloadImageFromMsgList(msgRecords)
|
||||
await useAudioStore().preloadAudioFromMsgList(msgRecords)
|
||||
await useVideoStore().preloadVideoFromMsgList(msgRecords)
|
||||
await useDocumentStore().preloadDocumentFromMsgList(msgRecords)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,7 +177,13 @@ export const useMessageStore = defineStore('anylink-message', () => {
|
||||
*/
|
||||
const removeMsgRecord = (sessionId, msgKey) => {
|
||||
if (msgRecordsList.value[sessionId] && msgKey in msgRecordsList.value[sessionId]) {
|
||||
delete msgRecordsList.value[sessionId][msgKey]
|
||||
msgRecordsList.value[sessionId][msgKey].delete = true
|
||||
}
|
||||
}
|
||||
|
||||
const revokeMsgRcord = (sessionId, msgKey) => {
|
||||
if (msgRecordsList.value[sessionId] && msgKey in msgRecordsList.value[sessionId]) {
|
||||
msgRecordsList.value[sessionId][msgKey].revoke = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +339,7 @@ export const useMessageStore = defineStore('anylink-message', () => {
|
||||
updateMsgKeySort,
|
||||
addMsgRecords,
|
||||
removeMsgRecord,
|
||||
revokeMsgRcord,
|
||||
getMsg,
|
||||
updateMsg,
|
||||
|
||||
|
||||
@@ -18,17 +18,30 @@ export const useVideoStore = defineStore('anylink-video', () => {
|
||||
video.value[obj.objectId] = obj
|
||||
}
|
||||
|
||||
const preloadVideo = async (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) {
|
||||
@@ -52,7 +65,9 @@ export const useVideoStore = defineStore('anylink-video', () => {
|
||||
return {
|
||||
video,
|
||||
setVideo,
|
||||
preloadVideo,
|
||||
localServerMap,
|
||||
setLocalServerMap,
|
||||
preloadVideoFromMsgList,
|
||||
clear
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import InputEditor from '@/views/message/components/InputEditor.vue'
|
||||
import MessageItem from '@/views/message/components/MessageItem.vue'
|
||||
import SessionTag from '@/views/message/components/SessionTag.vue'
|
||||
import SelectUserDialog from '@/components/common/SelectUserDialog.vue'
|
||||
import SelectSessionDialog from '@/components/common/SelectSessionDialog.vue'
|
||||
import {
|
||||
useUserStore,
|
||||
useSettingStore,
|
||||
@@ -32,7 +33,8 @@ import backgroupImage from '@/assets/svg/messagebx_bg.svg'
|
||||
import {
|
||||
msgChatPullMsgService,
|
||||
msgChatCreateSessionService,
|
||||
msgChatQuerySessionService
|
||||
msgChatQuerySessionService,
|
||||
msgChatDeleteMsgService
|
||||
} from '@/api/message'
|
||||
import { groupInfoService, groupCreateService } from '@/api/group'
|
||||
import { MsgType } from '@/proto/msg'
|
||||
@@ -41,15 +43,17 @@ import { onReceiveChatMsg, onReceiveGroupChatMsg, onReceiveGroupSystemMsg } from
|
||||
import { userQueryService } from '@/api/user'
|
||||
import { ElLoading, ElMessage } from 'element-plus'
|
||||
import { el_loading_options } from '@/const/commonConst'
|
||||
import { combineId, sessionIdConvert } from '@/js/utils/common'
|
||||
import SessionMenu from '@/views/message/components/SessionMenu.vue'
|
||||
import { combineId, jsonParseSafe, sessionIdConvert } from '@/js/utils/common'
|
||||
import MenuSession from '@/views/message/components/MenuSession.vue'
|
||||
import router from '@/router'
|
||||
import { BEGIN_MSG_ID, msgContentType, msgSendStatus } from '@/const/msgConst'
|
||||
import EditDialog from '@/components/common/EditDialog.vue'
|
||||
import AddOprMenu from '@/views/message/components/AddOprMenu.vue'
|
||||
import MenuAddOpr from '@/views/message/components/MenuAddOpr.vue'
|
||||
import MenuMsgMain from '@/views/message/components/MenuMsgMain.vue'
|
||||
import MessageGroupRightSide from '@/views/message/components/MessageGroupRightSide.vue'
|
||||
import HashNoData from '@/components/common/HasNoData.vue'
|
||||
import InputRecorder from '@/views/message/components/InputRecorder.vue'
|
||||
import InputMultiSelect from '@/views/message/components/InputMultiSelect.vue'
|
||||
import { playMsgSend } from '@/js/utils/audio'
|
||||
|
||||
const userData = useUserStore()
|
||||
@@ -115,34 +119,54 @@ const unreadAtRecords = computed(() => {
|
||||
return atRecords
|
||||
})
|
||||
|
||||
const handleShowHighlight = (msgId) => {
|
||||
let targetKey = msgId
|
||||
let element = document.querySelector(
|
||||
`#message-item-${sessionIdConvert(selectedSessionId.value)}-${msgId}`
|
||||
) // 先拿msgId定位到元素,如果不行再用msgKey
|
||||
if (!element) {
|
||||
// 用msgId逆向找msgKey
|
||||
for (const msgKey of msgKeysShow.value) {
|
||||
const msg = messageData.getMsg(selectedSessionId.value, msgKey)
|
||||
if (msg.msgId == msgId) {
|
||||
targetKey = msgKey
|
||||
element = document.querySelector(
|
||||
`#message-item-${sessionIdConvert(selectedSessionId.value)}-${msgKey}`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!element) {
|
||||
ElMessage.success('请加载更多消息后查找')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const msgListRect = msgListDiv.value.getBoundingClientRect()
|
||||
const rect = element.getBoundingClientRect()
|
||||
// 判断 element 是否在 msgListDiv 的视口内
|
||||
const isInViewport = rect.top >= msgListRect.top && rect.bottom <= msgListRect.bottom
|
||||
if (!isInViewport) {
|
||||
nextTick(() => {
|
||||
msgListDiv.value.scrollTo({
|
||||
top: msgListDiv.value.scrollTop - (msgListRect.top - rect.top),
|
||||
behavior: 'smooth'
|
||||
})
|
||||
})
|
||||
}
|
||||
highlightedMsgIds.value.add(targetKey + '')
|
||||
setTimeout(() => {
|
||||
highlightedMsgIds.value.delete(targetKey + '')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleReadAt = () => {
|
||||
const len = unreadAtRecords.value.length
|
||||
if (len === 0) return
|
||||
|
||||
const readReferMsgId = unreadAtRecords.value[len - 1].referMsgId
|
||||
const msgListRect = msgListDiv.value.getBoundingClientRect()
|
||||
const readElement = document.querySelector(
|
||||
`#message-item-${selectedSessionId.value}-${readReferMsgId}`
|
||||
)
|
||||
if (!readElement) {
|
||||
ElMessage.success('请加载更多消息后查找')
|
||||
} else {
|
||||
const rect = readElement.getBoundingClientRect()
|
||||
// 判断 readElement 是否在 msgListDiv 的视口内
|
||||
const isInViewport = rect.top >= msgListRect.top && rect.bottom <= msgListRect.bottom
|
||||
if (!isInViewport) {
|
||||
nextTick(() => {
|
||||
msgListDiv.value.scrollTo({
|
||||
top: msgListDiv.value.scrollTop - (msgListRect.top - rect.top),
|
||||
behavior: 'smooth'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
highlightedMsgIds.value.add(readReferMsgId + '')
|
||||
setTimeout(() => {
|
||||
highlightedMsgIds.value.delete(readReferMsgId + '')
|
||||
}, 2000)
|
||||
handleShowHighlight(readReferMsgId)
|
||||
readAtMsgIds.value.push(unreadAtRecords.value[len - 1].msgId)
|
||||
}
|
||||
|
||||
@@ -249,6 +273,7 @@ const initSession = (sessionId) => {
|
||||
}
|
||||
isShowRecorder.value = false // 麦克风输入状态重置
|
||||
inputRecorderRef.value?.cancelSend() // 取消音频发送
|
||||
inputMultiSelectRef.value?.cancel() // 取消多选模式
|
||||
imageData.clearImageInSession(sessionId) // 清除待渲染的图片队列
|
||||
readAtMsgIds.value = []
|
||||
}
|
||||
@@ -517,7 +542,7 @@ const handleSelectedSession = async (sessionId) => {
|
||||
// 如果是群组,要加载成员列表(显示消息需要account,nickName,avatar信息)
|
||||
if (selectedSession.value.sessionType === MsgType.GROUP_CHAT) {
|
||||
// 没有members数据才需要加载成员列表,加载过了就不重复加载了
|
||||
if (!groupMembers.value) {
|
||||
if (!groupMembers.value && !isNotInGroup.value) {
|
||||
const res = await groupInfoService({ groupId: selectedSession.value.remoteId })
|
||||
groupData.setGroupInfo({
|
||||
groupId: selectedSession.value.remoteId,
|
||||
@@ -554,7 +579,16 @@ const sendRead = () => {
|
||||
selectedSession.value.sessionType === MsgType.CHAT
|
||||
? MsgType.CHAT_READ
|
||||
: MsgType.GROUP_CHAT_READ
|
||||
wsConnect.sendMsg(selectedSessionId.value, showId.value, msgType, content + '', '', () => {})
|
||||
wsConnect.sendMsg(
|
||||
selectedSessionId.value,
|
||||
showId.value,
|
||||
msgType,
|
||||
content + '',
|
||||
0,
|
||||
null,
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
// 更新本地缓存的已读位置
|
||||
messageData.updateSession({
|
||||
sessionId: selectedSessionId.value,
|
||||
@@ -565,10 +599,110 @@ const sendRead = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理发送转发的消息
|
||||
*/
|
||||
const handleSendForwardMsg = async ({ session, content, contentType }) => {
|
||||
if (session.sessionType === MsgType.GROUP_CHAT && session.leave) {
|
||||
ElMessage.warning('您已离开该群或群已被解散')
|
||||
return
|
||||
}
|
||||
|
||||
if (session.sessionType === MsgType.GROUP_CHAT) {
|
||||
if (!groupData.groupMembersList[session.remoteId]) {
|
||||
const res = await groupInfoService({ groupId: session.remoteId })
|
||||
groupData.setGroupInfo({
|
||||
groupId: session.remoteId,
|
||||
groupInfo: res.data.data.groupInfo || {}
|
||||
})
|
||||
groupData.setGroupMembers({
|
||||
groupId: session.remoteId,
|
||||
members: res.data.data.members || {}
|
||||
})
|
||||
}
|
||||
|
||||
const meInGroup = groupData.groupMembersList[session.remoteId][myAccount.value]
|
||||
if (
|
||||
meInGroup.mutedMode === 1 ||
|
||||
(groupData.groupInfoList[session.remoteId].allMuted && meInGroup.mutedMode !== 2)
|
||||
) {
|
||||
ElMessage.warning('您已被禁言,请联系管理员')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const seq = uuidv4()
|
||||
const msg = {
|
||||
msgId: seq,
|
||||
seq,
|
||||
sessionId: session.sessionId,
|
||||
fromId: myAccount.value,
|
||||
remoteId: session.remoteId,
|
||||
msgType: session.sessionType,
|
||||
content,
|
||||
contentType,
|
||||
status: msgSendStatus.PENDING,
|
||||
msgTime: new Date(),
|
||||
sendTime: new Date()
|
||||
}
|
||||
messageData.addMsgRecords(msg.sessionId, [msg])
|
||||
messageData.updateMsgKeySort(msg.sessionId)
|
||||
|
||||
if (selectedSessionId.value === msg.sessionId) {
|
||||
capacity.value++
|
||||
msgListReachBottom()
|
||||
}
|
||||
|
||||
const resendInterval = 2000 //2秒
|
||||
const before = (data) => {
|
||||
setTimeout(() => {
|
||||
if (msg.status === msgSendStatus.PENDING) {
|
||||
wsConnect.sendAgent(data)
|
||||
setTimeout(() => {
|
||||
if (msg.status === msgSendStatus.PENDING) {
|
||||
wsConnect.sendAgent(data)
|
||||
setTimeout(() => {
|
||||
if (msg.status === msgSendStatus.PENDING) {
|
||||
wsConnect.sendAgent(data)
|
||||
setTimeout(() => {
|
||||
if (msg.status === msgSendStatus.PENDING) {
|
||||
messageData.updateMsg(msg.sessionId, msg.msgId, {
|
||||
status: msgSendStatus.FAILED
|
||||
})
|
||||
ElMessage.error('消息发送失败')
|
||||
}
|
||||
}, resendInterval)
|
||||
}
|
||||
}, resendInterval)
|
||||
}
|
||||
}, resendInterval)
|
||||
}
|
||||
}, resendInterval)
|
||||
}
|
||||
|
||||
const after = (msgId) => {
|
||||
messageData.updateMsg(msg.sessionId, msg.msgId, { msgId, status: msgSendStatus.OK })
|
||||
|
||||
if (!messageData.sessionList[msg.sessionId].dnd) {
|
||||
playMsgSend()
|
||||
}
|
||||
}
|
||||
wsConnect.sendMsg(
|
||||
msg.sessionId,
|
||||
msg.remoteId,
|
||||
msg.msgType,
|
||||
msg.content,
|
||||
msg.contentType,
|
||||
msg.seq,
|
||||
before,
|
||||
after
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送时先添加本地消息,可以立即渲染
|
||||
*/
|
||||
const handleLocalMsg = ({ content, contentType, objectId, fn }) => {
|
||||
const handleLocalMsg = ({ content, contentType, fn }) => {
|
||||
const seq = uuidv4()
|
||||
const msg = {
|
||||
msgId: seq,
|
||||
@@ -576,10 +710,8 @@ const handleLocalMsg = ({ content, contentType, objectId, fn }) => {
|
||||
sessionId: selectedSessionId.value,
|
||||
fromId: myAccount.value,
|
||||
msgType: selectedSession.value.sessionType,
|
||||
content:
|
||||
contentType === msgContentType.MIX
|
||||
? content
|
||||
: JSON.stringify({ type: contentType, value: objectId }),
|
||||
content,
|
||||
contentType,
|
||||
status: msgSendStatus.PENDING,
|
||||
msgTime: new Date(),
|
||||
sendTime: new Date()
|
||||
@@ -598,7 +730,7 @@ const handleLocalMsg = ({ content, contentType, objectId, fn }) => {
|
||||
fn(msg)
|
||||
}
|
||||
|
||||
const handleSendMessage = ({ msg, at }) => {
|
||||
const handleSendMessage = ({ msg, content, at }) => {
|
||||
if (isNotInGroup.value) {
|
||||
ElMessage.warning('您已离开该群或群已被解散')
|
||||
return
|
||||
@@ -659,7 +791,8 @@ const handleSendMessage = ({ msg, at }) => {
|
||||
msg.sessionId,
|
||||
showId.value,
|
||||
selectedSession.value.sessionType,
|
||||
msg.content,
|
||||
content,
|
||||
msg.contentType,
|
||||
msg.seq,
|
||||
before,
|
||||
after
|
||||
@@ -679,10 +812,9 @@ const handleResendMessage = (msg) => {
|
||||
})
|
||||
|
||||
const toSendAtList = []
|
||||
msg.content.split(/(<.*?>)/).forEach((item) => {
|
||||
if (item && item.startsWith('<') && item.endsWith('>')) {
|
||||
const index = item.indexOf('-')
|
||||
const account = item.slice(1, index) // 第一个字符是<,所以起点从1开始
|
||||
jsonParseSafe(msg.content).forEach((item) => {
|
||||
if (item.type === msgContentType.AT) {
|
||||
const account = item.value.account
|
||||
if (account == 0) {
|
||||
toSendAtList.push(account)
|
||||
} else {
|
||||
@@ -692,6 +824,7 @@ const handleResendMessage = (msg) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
handleSendMessage({ msg, at: toSendAtList })
|
||||
}
|
||||
|
||||
@@ -705,7 +838,7 @@ const handleSendAt = (at, sessionId, referMsgId) => {
|
||||
contentObj.isAtAll = true
|
||||
} else {
|
||||
contentObj.isAtAll = false
|
||||
contentObj.atList = at.filter((item) => groupMembers.value[item]) // 过滤脏数据
|
||||
contentObj.atList = at.filter((item) => groupMembers.value[item]) // 过滤不是当前合法群成员的数据
|
||||
}
|
||||
|
||||
if (contentObj.isAtAll || (!contentObj.isAtAll && contentObj.atList.length > 0)) {
|
||||
@@ -714,6 +847,7 @@ const handleSendAt = (at, sessionId, referMsgId) => {
|
||||
showId.value,
|
||||
MsgType.AT,
|
||||
JSON.stringify(contentObj),
|
||||
0,
|
||||
null,
|
||||
() => {},
|
||||
() => {}
|
||||
@@ -755,7 +889,7 @@ const updateScroll = () => {
|
||||
const msgListReachBottom = (behavior = 'instant') => {
|
||||
const scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
msgListDiv.value.scrollTo({
|
||||
msgListDiv.value?.scrollTo({
|
||||
top: msgListDiv.value.scrollHeight,
|
||||
behavior: behavior
|
||||
})
|
||||
@@ -829,7 +963,7 @@ const onShowUserCard = ({ sessionId, account }) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (messageData.sessionList[sessionId].sessionType === MsgType.GROUP_CHAT) {
|
||||
if (sessionId && messageData.sessionList[sessionId].sessionType === MsgType.GROUP_CHAT) {
|
||||
const groupId = selectedSession.value.remoteId
|
||||
groupData.setOneOfGroupMembers({
|
||||
groupId: groupId,
|
||||
@@ -1061,8 +1195,301 @@ const onSelectOprMenu = (label) => {
|
||||
}
|
||||
}
|
||||
|
||||
const showAddOprMenu = (e) => {
|
||||
addOprMenuRef.value.handleSessionMenu(e)
|
||||
const inputMultiSelectRef = ref(null)
|
||||
const isMultiSelect = ref(false)
|
||||
const multiSelectedMsgKeys = ref(new Set())
|
||||
const handleMsgItemSelect = (msgKey, selected) => {
|
||||
if (!isMultiSelect.value) {
|
||||
isMultiSelect.value = true
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
multiSelectedMsgKeys.value.add(msgKey)
|
||||
} else {
|
||||
multiSelectedMsgKeys.value.delete(msgKey)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancleMultiSelect = () => {
|
||||
isMultiSelect.value = false
|
||||
multiSelectedMsgKeys.value.clear()
|
||||
}
|
||||
|
||||
const handleForwardTogether = () => {
|
||||
isShowForwardMsgDialog.value = true
|
||||
showForwardMsgDialogTitle.value = '合并转发'
|
||||
}
|
||||
|
||||
const handleForwardOneByOne = () => {
|
||||
isShowForwardMsgDialog.value = true
|
||||
showForwardMsgDialogTitle.value = '逐条转发'
|
||||
}
|
||||
|
||||
const handleBatchDeleteMsg = () => {
|
||||
const deleteMsgIds = [...multiSelectedMsgKeys.value].map((item) => {
|
||||
return messageData.getMsg(selectedSessionId.value, item).msgId
|
||||
})
|
||||
msgChatDeleteMsgService({
|
||||
sessionId: selectedSessionId.value,
|
||||
deleteMsgIds: [...deleteMsgIds]
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data.code === 0) {
|
||||
multiSelectedMsgKeys.value.forEach((msgKey) => {
|
||||
messageData.removeMsgRecord(selectedSessionId.value, msgKey)
|
||||
})
|
||||
handleCancleMultiSelect()
|
||||
ElMessage.success('消息已删除')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
// 选区相关状态
|
||||
const selection = ref({
|
||||
isSelecting: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
currentX: 0,
|
||||
currentY: 0
|
||||
})
|
||||
|
||||
// 计算选区样式
|
||||
const selectionStyle = computed(() => {
|
||||
if (!selection.value.isSelecting) return { display: 'none' }
|
||||
|
||||
const left = Math.min(selection.value.startX, selection.value.currentX)
|
||||
const top = Math.min(selection.value.startY, selection.value.currentY)
|
||||
const width = Math.abs(selection.value.currentX - selection.value.startX)
|
||||
const height = Math.abs(selection.value.currentY - selection.value.startY)
|
||||
|
||||
return {
|
||||
display: 'block',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
}
|
||||
})
|
||||
|
||||
// 处理鼠标按下
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button !== 0) return // 如果不是左键则返回
|
||||
|
||||
// 检查是否已有选中内容,如果已有选中文本则终止多选操作
|
||||
if (window.getSelection().toString().trim() !== '') {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = msgListDiv.value.getBoundingClientRect()
|
||||
selection.value.isSelecting = true
|
||||
selection.value.startX = e.clientX - rect.left
|
||||
selection.value.startY = e.clientY - rect.top
|
||||
selection.value.currentX = selection.value.startX
|
||||
selection.value.currentY = selection.value.startY
|
||||
|
||||
// 添加全局监听
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove)
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp)
|
||||
}
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleGlobalMouseMove = (e) => {
|
||||
if (!selection.value.isSelecting) return
|
||||
|
||||
// 检查是否已有选中内容,如果已有选中文本则终止多选操作
|
||||
if (window.getSelection().toString().trim() !== '') {
|
||||
selection.value.isSelecting = false
|
||||
return
|
||||
}
|
||||
|
||||
const rect = msgListDiv.value.getBoundingClientRect()
|
||||
selection.value.currentX = e.clientX - rect.left
|
||||
selection.value.currentY = e.clientY - rect.top
|
||||
}
|
||||
|
||||
// 处理鼠标释放
|
||||
const handleGlobalMouseUp = (e) => {
|
||||
// 只在鼠标左键释放时处理
|
||||
if (e.button !== 0) return
|
||||
|
||||
// 检查是否已有选中内容,如果已有选中文本则终止多选操作
|
||||
if (window.getSelection().toString().trim() !== '') {
|
||||
selection.value.isSelecting = false
|
||||
return
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove)
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp)
|
||||
|
||||
if (
|
||||
!selection.value.isSelecting ||
|
||||
(selection.value.isSelecting &&
|
||||
selection.value.currentX === selection.value.startX &&
|
||||
selection.value.currentY === selection.value.startY)
|
||||
) {
|
||||
selection.value.isSelecting = false
|
||||
return
|
||||
}
|
||||
|
||||
selection.value.isSelecting = false
|
||||
|
||||
// 检测选区内的消息项
|
||||
const selectionRect = {
|
||||
left: Math.min(selection.value.startX, selection.value.currentX),
|
||||
top: Math.min(selection.value.startY, selection.value.currentY),
|
||||
right: Math.max(selection.value.startX, selection.value.currentX),
|
||||
bottom: Math.max(selection.value.startY, selection.value.currentY)
|
||||
}
|
||||
|
||||
const rect = msgListDiv.value.getBoundingClientRect()
|
||||
msgListDiv.value.querySelectorAll('.message-item').forEach((el) => {
|
||||
const itemRect = el.getBoundingClientRect()
|
||||
const itemLeft = itemRect.left - rect.left
|
||||
const itemTop = itemRect.top - rect.top
|
||||
const itemRight = itemRect.right - rect.left
|
||||
const itemBottom = itemRect.bottom - rect.top
|
||||
|
||||
const isIntersect = !(
|
||||
itemBottom < selectionRect.top ||
|
||||
itemTop > selectionRect.bottom ||
|
||||
itemRight < selectionRect.left ||
|
||||
itemLeft > selectionRect.right
|
||||
)
|
||||
|
||||
if (
|
||||
isIntersect &&
|
||||
(selectionRect.right - selectionRect.left > 50 || // 移动超过50 + 50才算有效
|
||||
selectionRect.bottom - selectionRect.top > 50)
|
||||
) {
|
||||
if (!isMultiSelect.value) {
|
||||
isMultiSelect.value = true
|
||||
}
|
||||
|
||||
const msgKey = el.dataset.msgKey
|
||||
const disabled = el.dataset.disabled
|
||||
if (disabled !== 'true' && !multiSelectedMsgKeys.value.has(msgKey)) {
|
||||
multiSelectedMsgKeys.value.add(msgKey)
|
||||
}
|
||||
|
||||
const cancelClick = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 移除监听,确保只生效一次
|
||||
msgListDiv.value.removeEventListener('click', cancelClick, true)
|
||||
}
|
||||
// 在捕获阶段拦截点击事件
|
||||
msgListDiv.value.addEventListener('click', cancelClick, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isShowForwardMsgDialog = ref(false)
|
||||
const showForwardMsgDialogTitle = ref('')
|
||||
|
||||
const sessionListSortedKey = computed(() => {
|
||||
return sessionListSorted.value
|
||||
.filter((item) => {
|
||||
return !(item.sessionType === MsgType.GROUP_CHAT && item.leave)
|
||||
})
|
||||
.map((item) => item.sessionId)
|
||||
})
|
||||
|
||||
const showForwardMsgDialog = (msgKey) => {
|
||||
multiSelectedMsgKeys.value.clear()
|
||||
multiSelectedMsgKeys.value.add(msgKey)
|
||||
isShowForwardMsgDialog.value = true
|
||||
showForwardMsgDialogTitle.value = '逐条转发'
|
||||
}
|
||||
|
||||
const handleConfirmForwardMsg = async (sessions) => {
|
||||
const loadingInstance = ElLoading.service(el_loading_options)
|
||||
try {
|
||||
for (const item of sessions) {
|
||||
const sessionId = item.sessionId
|
||||
const remoteId = item.remoteId
|
||||
// 如果没有session,先创建session
|
||||
if (!messageData.sessionList[sessionId]) {
|
||||
const res = await msgChatCreateSessionService({
|
||||
sessionId: sessionId,
|
||||
remoteId: remoteId,
|
||||
sessionType: item.sessionType
|
||||
})
|
||||
messageData.addSession(res.data.data.session)
|
||||
}
|
||||
|
||||
if (showForwardMsgDialogTitle.value === '逐条转发') {
|
||||
for (const msgKey of multiSelectedMsgKeys.value) {
|
||||
const msg = messageData.getMsg(selectedSessionId.value, msgKey)
|
||||
await handleSendForwardMsg({
|
||||
session: item,
|
||||
content: msg.content,
|
||||
contentType: msg.contentType
|
||||
})
|
||||
}
|
||||
} else if (showForwardMsgDialogTitle.value === '合并转发') {
|
||||
const msgs = [...multiSelectedMsgKeys.value].map((item) => {
|
||||
const msg = messageData.getMsg(selectedSessionId.value, item)
|
||||
let nickName = ''
|
||||
if (selectedSession.value.sessionType === MsgType.CHAT) {
|
||||
if (myAccount.value === msg.fromId) {
|
||||
nickName = userData.user.nickName
|
||||
} else {
|
||||
nickName = selectedSession.value.objectInfo.nickName
|
||||
}
|
||||
} else if (selectedSession.value.sessionType === MsgType.GROUP_CHAT) {
|
||||
const groupId = selectedSession.value.remoteId
|
||||
const members = groupData.groupMembersList[groupId]
|
||||
nickName = members[msg.fromId].nickName
|
||||
}
|
||||
return {
|
||||
nickName,
|
||||
msgId: msg.msgId
|
||||
}
|
||||
})
|
||||
await handleSendForwardMsg({
|
||||
session: item,
|
||||
content: JSON.stringify([
|
||||
{
|
||||
type: msgContentType.FORWARD,
|
||||
value: {
|
||||
sessionId: selectedSessionId.value,
|
||||
data: [...msgs]
|
||||
}
|
||||
}
|
||||
]),
|
||||
contentType: msgContentType.FORWARD
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('forward msg error: ', error)
|
||||
} finally {
|
||||
handleCloseForwardMsg()
|
||||
loadingInstance.close()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseForwardMsg = () => {
|
||||
isShowForwardMsgDialog.value = false
|
||||
showForwardMsgDialogTitle.value = ''
|
||||
handleCancleMultiSelect()
|
||||
}
|
||||
|
||||
const showMenuAddOpr = (e) => {
|
||||
addOprMenuRef.value.handleShowMenu(e)
|
||||
}
|
||||
|
||||
const onSelectMsgMainMenu = (label) => {
|
||||
switch (label) {
|
||||
case 'clearScreen':
|
||||
capacity.value = 0
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1129,7 +1556,7 @@ const onConfirmSelect = async (selected) => {
|
||||
|
||||
const inputEditorRef = ref()
|
||||
const onSendEmoji = (key) => {
|
||||
inputEditorRef.value.addEmoji(key)
|
||||
inputEditorRef.value?.addEmoji(key)
|
||||
}
|
||||
|
||||
const inputRecorderRef = ref(null)
|
||||
@@ -1149,12 +1576,12 @@ const onShowRecorder = () => {
|
||||
@showGroupCard="onShowGroupCard"
|
||||
@openSession="onOpenSession"
|
||||
></SearchBox>
|
||||
<AddOprMenu ref="addOprMenuRef" @selectMenu="onSelectOprMenu">
|
||||
<AddButton :size="30" @click="showAddOprMenu($event)"></AddButton>
|
||||
</AddOprMenu>
|
||||
<MenuAddOpr ref="addOprMenuRef" @selectMenu="onSelectOprMenu">
|
||||
<AddButton :size="30" @click="showMenuAddOpr($event)"></AddButton>
|
||||
</MenuAddOpr>
|
||||
</div>
|
||||
|
||||
<SessionMenu
|
||||
<MenuSession
|
||||
:sessionId="showMenuSessionId"
|
||||
@selectMenu="onSelectMenu"
|
||||
@closeMenu="onCloseSessionMenu"
|
||||
@@ -1182,7 +1609,7 @@ const onShowRecorder = () => {
|
||||
style="height: 100%"
|
||||
></HashNoData>
|
||||
</div>
|
||||
</SessionMenu>
|
||||
</MenuSession>
|
||||
</div>
|
||||
|
||||
<DragLine
|
||||
@@ -1268,28 +1695,38 @@ const onShowRecorder = () => {
|
||||
class="message-main my-scrollbar"
|
||||
ref="msgListDiv"
|
||||
@wheel="handleMsgListWheel"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<MessageItem
|
||||
v-for="item in msgKeysShow"
|
||||
:key="selectedSessionId + '-' + item"
|
||||
:id="'message-item-' + selectedSessionId + '-' + item"
|
||||
:class="{ highlighted: highlightedMsgIds.has(item) }"
|
||||
:sessionId="selectedSessionId"
|
||||
:msgKey="item"
|
||||
:extend="msgExtend[item]"
|
||||
:obj="getMsgSenderObj(item)"
|
||||
:readMsgId="selectedSession.readMsgId"
|
||||
:remoteRead="calibratedRemoteRead"
|
||||
:firstMsgId="firstMsgId"
|
||||
:lastMsgId="lastMsgId"
|
||||
:hasNoMoreMsg="hasNoMoreMsg"
|
||||
:isLoadMoreLoading="selectedSessionCache[selectedSessionId]?.isLoadMoreLoading"
|
||||
@loadMore="onLoadMore"
|
||||
@showUserCard="onShowUserCard"
|
||||
@showGroupCard="onShowGroupCard"
|
||||
@resendMsg="handleResendMessage"
|
||||
@loadFinished="updateScroll"
|
||||
></MessageItem>
|
||||
<div class="selection-box" :style="selectionStyle"></div>
|
||||
<MenuMsgMain @selectMenu="onSelectMsgMainMenu">
|
||||
<MessageItem
|
||||
v-for="item in msgKeysShow"
|
||||
:key="selectedSessionId + '-' + item"
|
||||
:id="'message-item-' + sessionIdConvert(selectedSessionId) + '-' + item"
|
||||
:class="{ highlighted: highlightedMsgIds.has(item) }"
|
||||
:sessionId="selectedSessionId"
|
||||
:msgKey="item"
|
||||
:extend="msgExtend[item]"
|
||||
:obj="getMsgSenderObj(item)"
|
||||
:readMsgId="selectedSession.readMsgId"
|
||||
:remoteRead="calibratedRemoteRead"
|
||||
:firstMsgId="firstMsgId"
|
||||
:lastMsgId="lastMsgId"
|
||||
:hasNoMoreMsg="hasNoMoreMsg"
|
||||
:isLoadMoreLoading="selectedSessionCache[selectedSessionId]?.isLoadMoreLoading"
|
||||
:inputEditorRef="inputEditorRef"
|
||||
:isMultiSelect="isMultiSelect"
|
||||
:isSelected="multiSelectedMsgKeys.has(item)"
|
||||
@loadMore="onLoadMore"
|
||||
@showUserCard="onShowUserCard"
|
||||
@showGroupCard="onShowGroupCard"
|
||||
@resendMsg="handleResendMessage"
|
||||
@loadFinished="updateScroll"
|
||||
@showHighlight="handleShowHighlight"
|
||||
@forwardMsg="showForwardMsgDialog"
|
||||
@select="handleMsgItemSelect"
|
||||
></MessageItem>
|
||||
</MenuMsgMain>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -1329,7 +1766,17 @@ const onShowRecorder = () => {
|
||||
</transition>
|
||||
</div>
|
||||
<div class="input-box bdr-t" :style="{ height: inputBoxHeight + 'px' }">
|
||||
<el-container v-if="isShowRecorder">
|
||||
<el-container v-if="isMultiSelect">
|
||||
<InputMultiSelect
|
||||
ref="inputMultiSelectRef"
|
||||
:selectedCount="multiSelectedMsgKeys.size"
|
||||
@exit="handleCancleMultiSelect"
|
||||
@forwardTogether="handleForwardTogether"
|
||||
@forwardOneByOne="handleForwardOneByOne"
|
||||
@batchDelete="handleBatchDeleteMsg"
|
||||
></InputMultiSelect>
|
||||
</el-container>
|
||||
<el-container v-else-if="isShowRecorder">
|
||||
<InputRecorder
|
||||
ref="inputRecorderRef"
|
||||
@exit="isShowRecorder = false"
|
||||
@@ -1413,6 +1860,20 @@ const onShowRecorder = () => {
|
||||
<div style="font-size: 16px; font-weight: bold; white-space: nowrap">创建群组</div>
|
||||
</template>
|
||||
</SelectUserDialog>
|
||||
<SelectSessionDialog
|
||||
v-model:isShow="isShowForwardMsgDialog"
|
||||
:sessionListSortedKey="sessionListSortedKey"
|
||||
@showUserCard="onShowUserCard"
|
||||
@showGroupCard="onShowGroupCard"
|
||||
@confirm="handleConfirmForwardMsg"
|
||||
@close="handleCloseForwardMsg"
|
||||
>
|
||||
<template #title>
|
||||
<div style="font-size: 16px; font-weight: bold; white-space: nowrap">
|
||||
{{ showForwardMsgDialogTitle }}
|
||||
</div>
|
||||
</template>
|
||||
</SelectSessionDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -1572,8 +2033,15 @@ const onShowRecorder = () => {
|
||||
padding: 10px;
|
||||
overflow-y: scroll; // 用它的滚动条
|
||||
|
||||
.message-item {
|
||||
transition: all 1s ease;
|
||||
.selection-box {
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.message-item-wrapper {
|
||||
transition: background-color 1s ease;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
|
||||
91
src/views/message/components/AgreeBeforeSend.vue
Normal 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>
|
||||
@@ -4,7 +4,7 @@ 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 { getFullPinyin, getInitialsPinyin } from '@/js/utils/common'
|
||||
import { smartMatch } from '@/js/utils/common'
|
||||
|
||||
const props = defineProps(['modelValue', 'sessionId', 'offsetX', 'offsetY', 'atKey'])
|
||||
const emit = defineEmits(['update:modelValue', 'selected'])
|
||||
@@ -46,17 +46,7 @@ const atList = computed(() => {
|
||||
list.unshift({ account: 0, avatarThumb: null, nickName: '所有人' })
|
||||
}
|
||||
|
||||
return list.filter((item) => {
|
||||
const searchKey = props.atKey.toLowerCase()
|
||||
const nickNameLower = item.nickName.toLowerCase()
|
||||
const pinyinFull = getFullPinyin(item.nickName)
|
||||
const pinyinInitials = getInitialsPinyin(item.nickName)
|
||||
return (
|
||||
nickNameLower.includes(searchKey) ||
|
||||
pinyinFull.includes(searchKey) ||
|
||||
pinyinInitials.includes(searchKey)
|
||||
)
|
||||
})
|
||||
return list.filter((item) => smartMatch(item.nickName, props.atKey))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
588
src/views/message/components/DialogForMsgForward.vue
Normal 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>
|
||||
1085
src/views/message/components/DialogForMsgHistory.vue
Normal file
@@ -3,27 +3,52 @@ import { QuillEditor, Delta, Quill } from '@vueup/vue-quill'
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||
import { computed, onMounted, onUnmounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useMessageStore, useImageStore } from '@/stores'
|
||||
import {
|
||||
useMessageStore,
|
||||
useImageStore,
|
||||
useAudioStore,
|
||||
useDocumentStore,
|
||||
useVideoStore
|
||||
} from '@/stores'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { emojis } from '@/js/utils/emojis'
|
||||
import { base64ToFile } from '@/js/utils/common'
|
||||
import { base64ToFile, jsonParseSafe, showTimeFormat } from '@/js/utils/common'
|
||||
import { mtsUploadServiceForImage } from '@/api/mts'
|
||||
import { msgContentType, msgFileUploadStatus, msgSendStatus } from '@/const/msgConst'
|
||||
import { getMd5 } from '@/js/utils/file'
|
||||
import { prehandleImage } from '@/js/utils/image'
|
||||
import { MsgType } from '@/proto/msg'
|
||||
import AtList from '@/views/message/components/AtList.vue'
|
||||
import AgreeBeforeSend from '@/views/message/components/AgreeBeforeSend.vue'
|
||||
import { isMatchMsgStruct, showSimplifyMsgContent } from '@/js/utils/message'
|
||||
import { msgChatQueryMessagesService } from '@/api/message'
|
||||
|
||||
/**
|
||||
* 处理粘贴格式问题
|
||||
* 处理复制/粘贴结构化数据
|
||||
*/
|
||||
const Clipboard = Quill.import('modules/clipboard')
|
||||
class PlainClipboard extends Clipboard {
|
||||
onPaste(range, { text }) {
|
||||
const delta = new Delta().retain(range.index).delete(range.length).insert(text)
|
||||
this.quill.updateContents(delta, Quill.sources.USER)
|
||||
this.quill.setSelection(delta.length() - range.length, Quill.sources.SILENT)
|
||||
this.quill.scrollSelectionIntoView()
|
||||
onPaste(range, data) {
|
||||
if (!data.html) {
|
||||
handlePaste(range, data.text)
|
||||
return
|
||||
}
|
||||
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(data.html, 'text/html')
|
||||
const elements = doc.querySelectorAll('[data-quill-custom]') // 查找所有具有 data-quill-custom 属性的元素
|
||||
if (elements.length > 0) {
|
||||
// 取第一个匹配的元素
|
||||
const encodedData = elements[0].getAttribute('data-quill-custom')
|
||||
const decodedData = decodeURIComponent(encodedData)
|
||||
handlePaste(range, decodedData)
|
||||
} else {
|
||||
handlePaste(range, data.text) // 降级方案
|
||||
}
|
||||
}
|
||||
|
||||
onCopy(range) {
|
||||
return handleCopy(range)
|
||||
}
|
||||
}
|
||||
Quill.register(
|
||||
@@ -66,10 +91,80 @@ class AtMention extends Embed {
|
||||
}
|
||||
Quill.register(AtMention, true)
|
||||
|
||||
class QuoteBlock extends Embed {
|
||||
static blotName = 'quoteBlock'
|
||||
static tagName = 'div'
|
||||
static className = 'quote-block'
|
||||
|
||||
constructor(scroll, node) {
|
||||
super(scroll, node)
|
||||
this.closeBtn = node.querySelector('.quote-close-btn')
|
||||
this.handleCloseClick = this.handleCloseClick.bind(this)
|
||||
this.closeBtn.addEventListener('click', this.handleCloseClick)
|
||||
}
|
||||
|
||||
handleCloseClick(e) {
|
||||
e.stopPropagation()
|
||||
const index = this.offset() // 当前Blot的位置
|
||||
quill.value.deleteText(index, 2) // 删除操作
|
||||
}
|
||||
|
||||
remove() {
|
||||
// 解绑事件监听器
|
||||
if (this.closeBtn) {
|
||||
this.closeBtn.removeEventListener('click', this.handleCloseClick)
|
||||
}
|
||||
super.remove()
|
||||
}
|
||||
|
||||
static create({ account, nickName, msgKey, msgId, content, msgTime }) {
|
||||
const node = super.create()
|
||||
node.dataset.account = account
|
||||
node.dataset.nickName = nickName
|
||||
node.dataset.msgKey = msgKey
|
||||
node.dataset.msgId = msgId
|
||||
node.dataset.msgTime = showTimeFormat(msgTime)
|
||||
node.dataset.content = content
|
||||
node.innerHTML = `
|
||||
<div class="quote-wrapper">
|
||||
<div class="quote-sender">
|
||||
<span class="quote-nickName">${node.dataset.nickName}</span>
|
||||
<span class="quote-msgTime">${node.dataset.msgTime}:</span>
|
||||
</div>
|
||||
<span class="quote-content">${showSimplifyMsgContent(node.dataset.content)}</span>
|
||||
<button type="button" class="quote-close-btn">
|
||||
<span >×</span>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
return node
|
||||
}
|
||||
|
||||
static value(node) {
|
||||
return {
|
||||
account: node.dataset.account,
|
||||
nickName: node.dataset.nickName,
|
||||
msgKey: node.dataset.msgKey,
|
||||
msgId: node.dataset.msgId,
|
||||
content: node.dataset.content,
|
||||
msgTime: node.dataset.msgTime
|
||||
}
|
||||
}
|
||||
|
||||
length() {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
Quill.register(QuoteBlock, true)
|
||||
|
||||
const props = defineProps(['sessionId', 'draft'])
|
||||
const emit = defineEmits(['saveLocalMsg', 'sendMessage'])
|
||||
const messageData = useMessageStore()
|
||||
const imageData = useImageStore()
|
||||
const audioData = useAudioStore()
|
||||
const documentData = useDocumentStore()
|
||||
const videoData = useVideoStore()
|
||||
const inputEditorRef = ref()
|
||||
const editorRef = ref()
|
||||
const isShowAtList = ref(false)
|
||||
@@ -78,11 +173,22 @@ const atKey = ref('')
|
||||
const atListOffsetX = ref(0)
|
||||
const atListOffsetY = ref(0)
|
||||
const toSendAtList = ref([])
|
||||
const showAgreeDialog = ref(false)
|
||||
|
||||
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 ''
|
||||
}
|
||||
})
|
||||
|
||||
const quill = computed(() => {
|
||||
return editorRef.value?.getQuill()
|
||||
})
|
||||
@@ -91,8 +197,8 @@ onMounted(async () => {
|
||||
toSendAtList.value = []
|
||||
// 给组件增加滚动条样式
|
||||
document.querySelector('.ql-editor').classList.add('my-scrollbar')
|
||||
await imageData.loadImageInfoFromContent(props.draft)
|
||||
renderContent(props.draft)
|
||||
await imageData.preloadImageFromMsg(props.draft)
|
||||
await renderContent(props.draft) // 渲染草稿
|
||||
quill.value.on('composition-start', () => {
|
||||
// 当用户使用拼音输入法开始输入汉字时,这个事件就会被触发
|
||||
quill.value.root.dataset.placeholder = ''
|
||||
@@ -104,7 +210,7 @@ onMounted(async () => {
|
||||
|
||||
// 监听文本变化检测@符号
|
||||
quill.value.on('text-change', (delta, oldDelta, source) => {
|
||||
if (session.value.sessionType === MsgType.GROUP_CHAT && source === 'user') {
|
||||
if (session.value.sessionType === MsgType.GROUP_CHAT && source === Quill.sources.USER) {
|
||||
const insertOps = delta.ops.filter((op) => op.insert && typeof op.insert === 'string')
|
||||
const insertContent = insertOps.map((item) => item.insert).join('')
|
||||
if (insertContent.length > 0) {
|
||||
@@ -193,6 +299,9 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('click', handleClick)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
@@ -214,10 +323,10 @@ onBeforeUnmount(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
fn(contentObj.contentFromLocal.join('').trim())
|
||||
fn(JSON.stringify(contentObj.contentFromLocal.filter((item) => item)))
|
||||
|
||||
callbacks.allUploadedSuccessFn = () => {
|
||||
fn(contentObj.contentFromServer.join('').trim())
|
||||
fn(JSON.stringify(contentObj.contentFromServer.filter((item) => item)))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -229,16 +338,43 @@ onUnmounted(() => {
|
||||
quill.value.off('composition-end')
|
||||
quill.value.destroy()
|
||||
}
|
||||
|
||||
document.removeEventListener('click', handleClick)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
cursorProtectForQuote()
|
||||
}
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'Home') {
|
||||
cursorProtectForQuote()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 光标守卫,防止光标移动到quote引用消息之前
|
||||
*/
|
||||
const cursorProtectForQuote = () => {
|
||||
if (quill.value.hasFocus()) {
|
||||
if (quill.value.scroll.descendants(QuoteBlock).length > 0) {
|
||||
const range = quill.value.getSelection()
|
||||
if (range.index < 3) {
|
||||
quill.value.setSelection(3, 0, Quill.sources.USER)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析输入框内容
|
||||
* @param callbacks 解析过程中需要触发的回调
|
||||
*/
|
||||
const parseContent = async (callbacks) => {
|
||||
const delta = quill.value.getContents()
|
||||
let contentFromLocal = new Array(delta.ops.length).fill('')
|
||||
let contentFromServer = new Array(delta.ops.length).fill('')
|
||||
let contentFromLocal = new Array(delta.ops.length).fill('') // 这里用new Array + index填充方式,而不用push,是为了保证内容的顺序
|
||||
let contentFromServer = new Array(delta.ops.length).fill('') // contentFromServer更新了某些服务端返回的数据
|
||||
let needUploadCount = 0 // 需要上传的图片个数
|
||||
let uploadedTotalCount = 0 // 已发上传请求的图片个数,包括上传成功和失败
|
||||
let uploadSuccessCount = 0 // 已经上传成功的图片个数
|
||||
@@ -259,30 +395,64 @@ const parseContent = async (callbacks) => {
|
||||
const insert = op.insert
|
||||
if (insert && typeof insert === 'string') {
|
||||
// 文本
|
||||
contentFromLocal[index] = insert
|
||||
contentFromServer[index] = insert
|
||||
let contentText = {}
|
||||
if (index === delta.ops.length - 1) {
|
||||
const lastInsert = insert.endsWith('\n') ? insert.slice(0, -1) : insert // 去除最后一个换行符
|
||||
if (lastInsert) {
|
||||
contentText = {
|
||||
type: msgContentType.TEXT,
|
||||
value: lastInsert
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
contentText = {
|
||||
type: msgContentType.TEXT,
|
||||
value: insert
|
||||
}
|
||||
}
|
||||
contentFromLocal[index] = contentText
|
||||
contentFromServer[index] = contentText
|
||||
} else if (insert && insert.atMention) {
|
||||
// 处理用于@的自定义Blot
|
||||
if (op.insert.atMention) {
|
||||
const { account, nickName } = insert.atMention
|
||||
contentFromLocal[index] = `<${account}-${nickName}>`
|
||||
contentFromServer[index] = `<${account}-${nickName}>`
|
||||
const { account, nickName } = insert.atMention
|
||||
const contentAt = { type: msgContentType.AT, value: { account, nickName } }
|
||||
contentFromLocal[index] = contentAt
|
||||
contentFromServer[index] = contentAt
|
||||
} else if (insert && insert.quoteBlock) {
|
||||
// 处理用于引用的自定义Blot
|
||||
contentFromLocal[index] = {
|
||||
type: msgContentType.QUOTE,
|
||||
value: {
|
||||
nickName: insert.quoteBlock.nickName,
|
||||
msgId: insert.quoteBlock.msgKey // 注意这里的区别
|
||||
}
|
||||
}
|
||||
contentFromServer[index] = {
|
||||
type: msgContentType.QUOTE,
|
||||
value: {
|
||||
nickName: insert.quoteBlock.nickName,
|
||||
msgId: insert.quoteBlock.msgId // 注意这里的区别
|
||||
}
|
||||
}
|
||||
} else if (insert && insert.image) {
|
||||
const alt = op.attributes?.alt
|
||||
if (alt && alt.startsWith('[') && alt.endsWith(']')) {
|
||||
// 表情id
|
||||
contentFromLocal[index] = alt
|
||||
contentFromServer[index] = alt
|
||||
const contentEmoji = { type: msgContentType.EMOJI, value: alt }
|
||||
contentFromLocal[index] = contentEmoji
|
||||
contentFromServer[index] = contentEmoji
|
||||
} else if (alt && alt.startsWith('{') && alt.endsWith('}')) {
|
||||
// 图片id
|
||||
contentFromLocal[index] = alt
|
||||
contentFromServer[index] = alt
|
||||
// 已有objectId的截图,说明是已上传过服务端的
|
||||
const contentSceenShot = { type: msgContentType.SCREENSHOT, value: alt.slice(1, -1) }
|
||||
contentFromLocal[index] = contentSceenShot
|
||||
contentFromServer[index] = contentSceenShot
|
||||
} else if (insert.image.startsWith('data:') && insert.image.includes('base64')) {
|
||||
// base64编码的图片
|
||||
// 截图的原始base64编码的图片
|
||||
const file = base64ToFile(insert.image, uuidv4()) // base64转file
|
||||
const tempObjectId = new Date().getTime()
|
||||
contentFromLocal[index] = `{${tempObjectId}}`
|
||||
contentFromLocal[index] = { type: msgContentType.SCREENSHOT, value: tempObjectId }
|
||||
// 发送的时候设置本地缓存(非服务端数据),用于立即渲染
|
||||
const md5 = await getMd5(file)
|
||||
const prehandleImageObj = await prehandleImage(file)
|
||||
@@ -302,7 +472,7 @@ const parseContent = async (callbacks) => {
|
||||
thumbFile: prehandleImageObj.thumbFile
|
||||
}
|
||||
const requestBody = {
|
||||
storeType: 0,
|
||||
storeType: 1,
|
||||
md5,
|
||||
fileName: file.name,
|
||||
fileRawType: file.type,
|
||||
@@ -317,8 +487,12 @@ const parseContent = async (callbacks) => {
|
||||
mtsUploadServiceForImage(requestBody, files)
|
||||
.then((res) => {
|
||||
imageData.setImage(res.data.data) // 缓存image数据
|
||||
imageData.setLocalServerMap(tempObjectId, res.data.data.objectId)
|
||||
uploadSuccessCount++
|
||||
contentFromServer[index] = `{${res.data.data.objectId}}`
|
||||
contentFromServer[index] = {
|
||||
type: msgContentType.SCREENSHOT,
|
||||
value: res.data.data.objectId
|
||||
}
|
||||
callbacks.someOneUploadedSuccessFn()
|
||||
if (uploadSuccessCount === needUploadCount) {
|
||||
callbacks.allUploadedSuccessFn()
|
||||
@@ -332,8 +506,9 @@ const parseContent = async (callbacks) => {
|
||||
})
|
||||
} else {
|
||||
// 当文本处理
|
||||
contentFromLocal[index] = insert
|
||||
contentFromServer[index] = insert
|
||||
const contentText = { type: msgContentType.TEXT, value: insert }
|
||||
contentFromLocal[index] = contentText
|
||||
contentFromServer[index] = contentText
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,75 +545,270 @@ watch(
|
||||
}
|
||||
|
||||
callbacks.allUploadedSuccessFn = () => {
|
||||
fn(contentObj.contentFromServer.join('').trim())
|
||||
// JSON.stringify(contentObj.contentFromServer.filter((item) => item))在空值时返回'[]''
|
||||
let inputContent = JSON.stringify(contentObj.contentFromServer.filter((item) => item))
|
||||
fn(inputContent === '[]' ? '' : inputContent)
|
||||
}
|
||||
|
||||
fn(contentObj.contentFromLocal.join('').trim())
|
||||
// JSON.stringify(contentObj.contentFromLocal.filter((item) => item))在空值时返回'[]''
|
||||
let inputContent = JSON.stringify(contentObj.contentFromLocal.filter((item) => item))
|
||||
fn(inputContent === '[]' ? '' : inputContent)
|
||||
|
||||
renderContent(messageData.sessionList[newSessionId].draft || '')
|
||||
await renderContent(messageData.sessionList[newSessionId].draft || '') // 切换session时渲染新session的草稿
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const pasteObj = {
|
||||
content: null,
|
||||
contentType: null,
|
||||
fileName: null,
|
||||
fileSize: null,
|
||||
url: null
|
||||
}
|
||||
|
||||
const clearPasteObj = () => {
|
||||
pasteObj.content = null
|
||||
pasteObj.contentType = null
|
||||
pasteObj.fileName = null
|
||||
pasteObj.fileSize = null
|
||||
pasteObj.url = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 把输入框的字符串内容渲染成富媒体内容
|
||||
* @param content 字符串内容
|
||||
* 处理复制
|
||||
*/
|
||||
const renderContent = (content) => {
|
||||
const handleCopy = ({ index, length }) => {
|
||||
const delta = quill.value.getContents(index, length)
|
||||
|
||||
const clipboardContent = []
|
||||
let clipboardText = ''
|
||||
|
||||
for (let index = 0; index < delta.ops.length; index++) {
|
||||
const op = delta.ops[index]
|
||||
const insert = op.insert
|
||||
if (insert && typeof insert === 'string') {
|
||||
// 文本
|
||||
clipboardContent.push({
|
||||
type: msgContentType.TEXT,
|
||||
value: insert
|
||||
})
|
||||
clipboardText += insert
|
||||
} else if (insert && insert.image) {
|
||||
const alt = op.attributes?.alt
|
||||
if (alt && alt.startsWith('[') && alt.endsWith(']')) {
|
||||
// 表情
|
||||
clipboardContent.push({ type: msgContentType.EMOJI, value: alt })
|
||||
} else if (alt && alt.startsWith('{') && alt.endsWith('}')) {
|
||||
// 已有objectId的截图,复制原消息粘贴的,撤回重新编辑,从草稿渲染
|
||||
clipboardContent.push({ type: msgContentType.SCREENSHOT, value: alt.slice(1, -1) })
|
||||
} else if (insert.image.startsWith('data:') && insert.image.includes('base64')) {
|
||||
// 截图后原始的base64编码
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 在html自定义属性data-quill-custom中传递clipboardContent结构化数据
|
||||
html: `<div data-quill-custom=${encodeURIComponent(JSON.stringify(clipboardContent))}></div>`,
|
||||
text: clipboardText // 纯文本
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理粘贴
|
||||
* @param range
|
||||
*/
|
||||
const handlePaste = (range, text) => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMatchMsgStruct(text)) {
|
||||
const delta = new Delta().retain(range.index).delete(range.length).insert(text)
|
||||
quill.value.updateContents(delta, Quill.sources.USER)
|
||||
quill.value.setSelection(delta.length() - range.length, Quill.sources.USER)
|
||||
return
|
||||
}
|
||||
|
||||
const delta = new Delta().retain(range.index).delete(range.length)
|
||||
|
||||
const arr = jsonParseSafe(text)
|
||||
for (const item of arr) {
|
||||
const { type, value } = item
|
||||
if (
|
||||
type === msgContentType.IMAGE ||
|
||||
type === msgContentType.AUDIO ||
|
||||
type === msgContentType.VIDEO ||
|
||||
type === msgContentType.DOCUMENT
|
||||
) {
|
||||
clearPasteObj()
|
||||
pasteObj.content = { type }
|
||||
pasteObj.contentType = type
|
||||
const fileId = value
|
||||
switch (type) {
|
||||
case msgContentType.IMAGE:
|
||||
pasteObj.content.value = imageData.localServerMap[value] || value // 避免使用本地对象ID
|
||||
pasteObj.fileName = imageData.image[fileId]?.fileName
|
||||
pasteObj.fileSize = imageData.image[fileId]?.size
|
||||
pasteObj.url = imageData.image[fileId]?.thumbUrl
|
||||
break
|
||||
case msgContentType.AUDIO:
|
||||
pasteObj.content.value = audioData.localServerMap[value] || value
|
||||
pasteObj.fileName = audioData.audio[fileId]?.fileName
|
||||
pasteObj.fileSize = audioData.audio[fileId]?.size
|
||||
break
|
||||
case msgContentType.VIDEO:
|
||||
pasteObj.content.value = videoData.localServerMap[value] || value
|
||||
pasteObj.fileName = videoData.video[fileId]?.fileName
|
||||
pasteObj.fileSize = videoData.video[fileId]?.size
|
||||
break
|
||||
case msgContentType.DOCUMENT:
|
||||
pasteObj.content.value = documentData.localServerMap[value] || value
|
||||
pasteObj.fileName = documentData.document[fileId]?.fileName
|
||||
pasteObj.fileSize = documentData.document[fileId]?.size
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// 文件确实存在才发送
|
||||
if (pasteObj.fileName) {
|
||||
showAgreeDialog.value = true
|
||||
return // 这四种类型的数组只能有1个元素,所以直接return
|
||||
}
|
||||
} else {
|
||||
switch (type) {
|
||||
case msgContentType.TEXT:
|
||||
delta.insert(value)
|
||||
break
|
||||
case msgContentType.EMOJI: {
|
||||
const emojiUrl = emojis[value]
|
||||
if (emojiUrl) {
|
||||
delta.insert({ image: emojiUrl }, { alt: value })
|
||||
} else {
|
||||
delta.insert(value)
|
||||
}
|
||||
break
|
||||
}
|
||||
case msgContentType.SCREENSHOT: {
|
||||
const imageId = imageData.localServerMap[value] || value
|
||||
const imageUrl = imageData.image[imageId]?.originUrl
|
||||
if (imageUrl) {
|
||||
delta.insert({ image: imageUrl }, { alt: `{${imageId}}` }) // 添加区别于emoji表情alt的符号,方便parse时识别
|
||||
} else {
|
||||
delta.insert(imageId)
|
||||
}
|
||||
break
|
||||
}
|
||||
case msgContentType.AT: {
|
||||
const { account, nickName } = value
|
||||
toSendAtList.value.push(account)
|
||||
delta.insert({ atMention: { account, nickName } })
|
||||
break
|
||||
}
|
||||
case msgContentType.QUOTE:
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
quill.value.updateContents(delta, Quill.sources.USER)
|
||||
quill.value.setSelection(delta.length() - range.length, Quill.sources.USER)
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入框从空状态渲染可视内容
|
||||
* 1. 渲染草稿
|
||||
* 2. 消息撤回后的重新编辑
|
||||
* @param content json结构化内容的字符串
|
||||
*/
|
||||
const renderContent = async (content) => {
|
||||
if (!content) {
|
||||
quill.value.setText('')
|
||||
return
|
||||
}
|
||||
|
||||
let contentArray = []
|
||||
//匹配内容中的图片
|
||||
content.split(/(\{.*?\})/).forEach((item) => {
|
||||
//匹配内容中的表情
|
||||
item.split(/(\[.*?\])/).forEach((item) => {
|
||||
//匹配内容中的@
|
||||
item.split(/(<.*?>)/).forEach((item) => {
|
||||
if (item) {
|
||||
contentArray.push(item)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
const arr = jsonParseSafe(content)
|
||||
// 不允许非结构化的content
|
||||
if (!arr || !Array.isArray(arr) || arr.length === 0) {
|
||||
quill.value.setText('')
|
||||
return
|
||||
}
|
||||
|
||||
// 创建一个新的 Delta 对象
|
||||
const delta = new Delta()
|
||||
contentArray.map((item) => {
|
||||
if (item.startsWith('{') && item.endsWith('}')) {
|
||||
const imageId = item.slice(1, -1)
|
||||
const imageUrl = imageData.image[imageId].originUrl
|
||||
delta.insert({ image: imageUrl }, { alt: item })
|
||||
} else if (item.startsWith('[') && item.endsWith(']')) {
|
||||
const emojiUrl = emojis[item]
|
||||
delta.insert({ image: emojiUrl }, { alt: item })
|
||||
} else if (item.startsWith('<') && item.endsWith('>')) {
|
||||
const content = item.slice(1, -1)
|
||||
const index = content.indexOf('-')
|
||||
if (index !== -1) {
|
||||
const account = content.slice(0, index)
|
||||
const nickName = content.slice(index + 1)
|
||||
if (nickName) {
|
||||
toSendAtList.value.push(account)
|
||||
delta.insert({ atMention: { account, nickName } })
|
||||
} else {
|
||||
delta.insert(item)
|
||||
}
|
||||
} else {
|
||||
delta.insert(item)
|
||||
}
|
||||
} else {
|
||||
delta.insert(item)
|
||||
for (const item of arr) {
|
||||
if (!item.type || !item.value) {
|
||||
delta.insert('')
|
||||
}
|
||||
})
|
||||
|
||||
switch (item.type) {
|
||||
case msgContentType.TEXT:
|
||||
delta.insert(item.value)
|
||||
break
|
||||
case msgContentType.EMOJI: {
|
||||
const emojiUrl = emojis[item.value]
|
||||
if (emojiUrl) {
|
||||
delta.insert({ image: emojiUrl }, { alt: item.value })
|
||||
} else {
|
||||
delta.insert(item.value)
|
||||
}
|
||||
break
|
||||
}
|
||||
case msgContentType.SCREENSHOT: {
|
||||
const imageUrl = imageData.image[item.value]?.originUrl
|
||||
if (imageUrl) {
|
||||
delta.insert({ image: imageUrl }, { alt: `{${item.value}}` }) // 添加区别于emoji表情alt的符号,方便parse时识别
|
||||
} else {
|
||||
delta.insert(item.value)
|
||||
}
|
||||
break
|
||||
}
|
||||
case msgContentType.AT: {
|
||||
const { account, nickName } = item.value
|
||||
toSendAtList.value.push(account)
|
||||
delta.insert({ atMention: { account, nickName } })
|
||||
break
|
||||
}
|
||||
case msgContentType.QUOTE: {
|
||||
// 先从本地消息缓存中获取
|
||||
let msg = messageData.getMsg(props.sessionId, item.value.msgId)
|
||||
if (!msg) {
|
||||
// 如果本地消息缓存中没有,再去服务器查询
|
||||
const res = await msgChatQueryMessagesService({
|
||||
sessionId: props.sessionId,
|
||||
msgIds: [item.value.msgId]
|
||||
})
|
||||
|
||||
if (res.data.data && res.data.data.length > 0) {
|
||||
msg = res.data.data[0]
|
||||
}
|
||||
}
|
||||
delta.insert({
|
||||
quoteBlock: {
|
||||
account: msg.fromId,
|
||||
nickName: item.value.nickName,
|
||||
msgId: msg.msgId,
|
||||
content: showSimplifyMsgContent(msg.content),
|
||||
msgTime: msg.msgTime
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
delta.insert('')
|
||||
}
|
||||
}
|
||||
|
||||
quill.value.setText('') // 清空编辑器内容
|
||||
quill.value.updateContents(delta) // 使用 Delta 对象更新编辑器内容
|
||||
quill.value.setSelection(quill.value.getLength(), 0, 'user') // 设置光标位置
|
||||
quill.value.setSelection(quill.value.getLength(), 0, Quill.sources.USER) // 设置光标位置
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Enter发送
|
||||
*/
|
||||
const handleEnter = async () => {
|
||||
if (isShowAtList.value) {
|
||||
return
|
||||
@@ -450,14 +820,26 @@ const handleEnter = async () => {
|
||||
allUploadedSuccessFn: () => {}
|
||||
}
|
||||
|
||||
const contentObj = await parseContent(callbacks)
|
||||
const contentObj = pasteObj.content
|
||||
? { contentFromLocal: [pasteObj.content], contentFromServer: [pasteObj.content] }
|
||||
: await parseContent(callbacks)
|
||||
|
||||
const content = contentObj.contentFromLocal.join('').trim()
|
||||
if (!content) {
|
||||
let textLength = 0
|
||||
let contentType = 0
|
||||
contentObj.contentFromLocal.forEach((item) => {
|
||||
if (item.type === msgContentType.TEXT) {
|
||||
textLength = textLength + item.value.length
|
||||
}
|
||||
contentType = contentType | item.type
|
||||
})
|
||||
|
||||
if (contentType === 0) {
|
||||
ElMessage.warning('请勿发送空内容')
|
||||
quill.value.setText('')
|
||||
return
|
||||
} else if (content.length > 3000) {
|
||||
}
|
||||
|
||||
if (textLength > 3000) {
|
||||
ElMessage.warning('发送内容请不要超过3000个字')
|
||||
return
|
||||
}
|
||||
@@ -465,8 +847,8 @@ const handleEnter = async () => {
|
||||
// 发送的时候设置本地缓存(非服务端数据),用于立即渲染
|
||||
let msg = {}
|
||||
emit('saveLocalMsg', {
|
||||
contentType: msgContentType.MIX,
|
||||
content: content,
|
||||
contentType: contentType,
|
||||
content: JSON.stringify(contentObj.contentFromLocal.filter((item) => item)),
|
||||
fn: (result) => {
|
||||
msg = result
|
||||
}
|
||||
@@ -478,7 +860,8 @@ const handleEnter = async () => {
|
||||
uploadProgress: 0
|
||||
})
|
||||
} else {
|
||||
emit('sendMessage', { msg, at: toSendAtList.value })
|
||||
const content = JSON.stringify(contentObj.contentFromServer.filter((item) => item))
|
||||
emit('sendMessage', { msg, content, at: toSendAtList.value }) // content 要更新后发给服务端,和saveLocalMsg的本地消息由些许差异
|
||||
}
|
||||
|
||||
// callback:每成功上传一个图片,更新一下进度
|
||||
@@ -497,18 +880,20 @@ const handleEnter = async () => {
|
||||
}
|
||||
|
||||
// callback:所有图片均上传,则发送消息
|
||||
const atTargets = toSendAtList.value
|
||||
const atTargets = toSendAtList.value // 异步函数里避免调用响应式数据
|
||||
callbacks.allUploadedSuccessFn = () => {
|
||||
messageData.updateMsg(msg.sessionId, msg.msgId, {
|
||||
uploadStatus: msgFileUploadStatus.UPLOAD_SUCCESS,
|
||||
uploadProgress: 100
|
||||
})
|
||||
msg.content = contentObj.contentFromServer.join('').trim()
|
||||
emit('sendMessage', { msg, atTargets })
|
||||
const content = JSON.stringify(contentObj.contentFromServer.filter((item) => item))
|
||||
emit('sendMessage', { msg, content, at: atTargets })
|
||||
}
|
||||
|
||||
quill.value.setText('') // 编辑窗口置空
|
||||
clearPasteObj()
|
||||
toSendAtList.value = []
|
||||
quill.value.setText('') // 编辑窗口置空
|
||||
quill.value.setSelection(0, 0, Quill.sources.USER) // 设置光标位置
|
||||
}
|
||||
|
||||
const options = {
|
||||
@@ -545,7 +930,7 @@ const addEmoji = (key) => {
|
||||
delta.retain(index)
|
||||
delta.insert({ image: emojis[key] }, { alt: key })
|
||||
quill.value.updateContents(delta)
|
||||
quill.value.setSelection(index + 1, 0, 'user')
|
||||
quill.value.setSelection(index + 1, 0, Quill.sources.USER)
|
||||
}
|
||||
|
||||
const onSelectedAtTarget = ({ account, nickName }) => {
|
||||
@@ -558,18 +943,60 @@ const onSelectedAtTarget = ({ account, nickName }) => {
|
||||
if (range.index >= atIndex.value) {
|
||||
const delLen = range.index - atIndex.value + 1 // 删除用户输入的@符号及搜索关键字
|
||||
quill.value.deleteText(atIndex.value - 1, delLen)
|
||||
quill.value.insertEmbed(atIndex.value - 1, 'atMention', { account, nickName }, 'user') // 插入Blot(占据1个位置)
|
||||
quill.value.insertText(atIndex.value, ' ', 'user') // 插入空格
|
||||
quill.value.setSelection(atIndex.value + 1, 0, 'user') // 定位光标
|
||||
quill.value.insertEmbed(
|
||||
atIndex.value - 1,
|
||||
'atMention',
|
||||
{ account, nickName },
|
||||
Quill.sources.USER
|
||||
) // 插入Blot(占据1个位置)
|
||||
quill.value.insertText(atIndex.value, ' ', Quill.sources.USER) // 插入空格
|
||||
quill.value.setSelection(atIndex.value + 1, 0, Quill.sources.USER) // 定位光标
|
||||
} else {
|
||||
quill.value.insertEmbed(range.index, 'atMention', { account, nickName }, 'user') // 插入Blot(占据1个位置)
|
||||
quill.value.insertText(range.index + 1, ' ', 'user') // 插入空格
|
||||
quill.value.setSelection(range.index + 1 + 1, 0, 'user') // 定位光标
|
||||
quill.value.insertEmbed(range.index, 'atMention', { account, nickName }, Quill.sources.USER) // 插入Blot(占据1个位置)
|
||||
quill.value.insertText(range.index + 1, ' ', Quill.sources.USER) // 插入空格
|
||||
quill.value.setSelection(range.index + 1 + 1, 0, Quill.sources.USER) // 定位光标
|
||||
}
|
||||
}
|
||||
|
||||
const reeditFromRevoke = async (content) => {
|
||||
quill.value.setText('') // 清空编辑器内容
|
||||
quill.value.setSelection(0, 0, Quill.sources.SILENT) // 设置光标位置
|
||||
await renderContent(content)
|
||||
}
|
||||
|
||||
const insertQuote = ({ account, nickName, msgKey, msgId, content, msgTime }) => {
|
||||
// 1. 保存原始选择范围
|
||||
quill.value.focus() // 先使 Quill 编辑器获取焦点,否则无法获取Selection
|
||||
const originalRange = quill.value.getSelection()
|
||||
const len = !originalRange ? 0 : originalRange.length
|
||||
let newIndex = !originalRange ? 0 : originalRange.index
|
||||
|
||||
// 2. 删除所有旧的引用块及其后的换行符
|
||||
const quoteBlots = quill.value.scroll.descendants(QuoteBlock)
|
||||
quoteBlots.forEach((blot) => {
|
||||
const index = quill.value.getIndex(blot)
|
||||
quill.value.deleteText(index, 2, Quill.sources.SILENT) // 删除块和换行符
|
||||
newIndex = newIndex - 2
|
||||
})
|
||||
|
||||
// 3. 插入新引用块到开头
|
||||
quill.value.insertEmbed(
|
||||
0,
|
||||
'quoteBlock',
|
||||
{ account, nickName, msgKey, msgId, content, msgTime },
|
||||
Quill.sources.USER
|
||||
)
|
||||
quill.value.insertText(1, '\n', Quill.sources.SILENT)
|
||||
newIndex = newIndex + 2
|
||||
|
||||
// 4. 恢复原始光标位置(如果有)
|
||||
quill.value.setSelection(newIndex, len, Quill.sources.USER)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
addEmoji
|
||||
addEmoji,
|
||||
reeditFromRevoke,
|
||||
insertQuote
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -589,6 +1016,15 @@ defineExpose({
|
||||
:atKey="atKey"
|
||||
@selected="onSelectedAtTarget"
|
||||
></AtList>
|
||||
<AgreeBeforeSend
|
||||
v-model:isShow="showAgreeDialog"
|
||||
:target="remoteName"
|
||||
:contentType="pasteObj.contentType"
|
||||
:fileName="pasteObj.fileName"
|
||||
:fileSize="pasteObj.fileSize"
|
||||
:src="pasteObj.url"
|
||||
@confirm="handleEnter"
|
||||
></AgreeBeforeSend>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -628,4 +1064,89 @@ img {
|
||||
cursor: default; /* 显示默认光标 */
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.quote-block {
|
||||
max-width: 480px;
|
||||
width: fit-content; /* 宽高根据内容自适应,需要display: flex配合*/
|
||||
height: fit-content; /* 宽高根据内容自适应,需要display: flex配合*/
|
||||
display: flex;
|
||||
position: relative;
|
||||
background-color: #f5f5f5;
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
|
||||
span[contenteditable='false'] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: fit-content; /* 高度根据内容自适应,需要display: flex配合 */
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.quote-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: baseline; /* 基线对齐 */
|
||||
margin-bottom: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quote-sender {
|
||||
padding-right: 40px;
|
||||
display: flex;
|
||||
color: gray;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.quote-content {
|
||||
width: 100%;
|
||||
color: #666;
|
||||
white-space: nowrap; //防止文本自动换行,确保在一行内显示,这样当文本超出宽度时才会触发省略号
|
||||
overflow: hidden; //当文本超出元素范围时,隐藏超出的部分。
|
||||
text-overflow: ellipsis; //在文本溢出并且overflow属性设置为hidden时,显示省略号。
|
||||
}
|
||||
|
||||
/* 重置按钮默认样式 */
|
||||
.quote-close-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
/* 交互优化 */
|
||||
.quote-close-btn:hover {
|
||||
color: #666;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.quote-close-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 关闭符号样式 */
|
||||
.quote-close-btn > span {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
margin-top: -1px; /* 视觉居中调整 */
|
||||
}
|
||||
</style>
|
||||
|
||||
134
src/views/message/components/InputMultiSelect.vue
Normal 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>
|
||||
@@ -161,8 +161,8 @@ const uploadRecord = async () => {
|
||||
})
|
||||
let msg = {}
|
||||
emit('saveLocalMsg', {
|
||||
content: JSON.stringify([{ type: msgContentType.RECORDING, value: tempObjectId }]),
|
||||
contentType: msgContentType.RECORDING,
|
||||
objectId: tempObjectId,
|
||||
fn: (result) => {
|
||||
msg = result
|
||||
}
|
||||
@@ -178,7 +178,7 @@ const uploadRecord = async () => {
|
||||
originFile: file
|
||||
}
|
||||
const requestBody = {
|
||||
storeType: 0,
|
||||
storeType: 1,
|
||||
md5,
|
||||
fileName: file.name,
|
||||
fileRawType: file.type,
|
||||
@@ -190,15 +190,18 @@ const uploadRecord = async () => {
|
||||
.then((res) => {
|
||||
if (res.data.code === 0) {
|
||||
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
|
||||
})
|
||||
msg.content = JSON.stringify({
|
||||
type: msgContentType.RECORDING,
|
||||
value: res.data.data.objectId
|
||||
})
|
||||
emit('sendMessage', { msg })
|
||||
const content = JSON.stringify([
|
||||
{
|
||||
type: msgContentType.RECORDING,
|
||||
value: res.data.data.objectId
|
||||
}
|
||||
])
|
||||
emit('sendMessage', { msg, content })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { Clock, Microphone } from '@element-plus/icons-vue'
|
||||
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, mtsUploadServiceForImage } from '@/api/mts'
|
||||
import {
|
||||
@@ -22,6 +22,8 @@ import { msgContentType, msgFileUploadStatus, msgSendStatus } from '@/const/msgC
|
||||
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', 'showRecorder', 'sendMessage', 'saveLocalMsg'])
|
||||
@@ -32,18 +34,37 @@ const audioData = useAudioStore()
|
||||
const videoData = useVideoStore()
|
||||
const documentData = useDocumentStore()
|
||||
const isShowEmojiBox = ref(false)
|
||||
const showAgreeDialog = ref(false)
|
||||
const isShowHistoryDialog = ref(false)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let contentType = msgContentType.DOCUMENT
|
||||
let md5
|
||||
let prehandleImageObj
|
||||
let prehandleVideoObj
|
||||
let msg = {}
|
||||
|
||||
selectedFile = file
|
||||
localSrc = URL.createObjectURL(selectedFile.raw)
|
||||
try {
|
||||
md5 = await getMd5(file.raw)
|
||||
if (file.raw.type.startsWith('image/')) {
|
||||
@@ -54,31 +75,42 @@ const onSelectedFile = async (file) => {
|
||||
} else if (file.raw.type.startsWith('video/')) {
|
||||
contentType = msgContentType.VIDEO
|
||||
prehandleVideoObj = await prehandleVideo(file.raw)
|
||||
} else {
|
||||
contentType = msgContentType.DOCUMENT
|
||||
}
|
||||
|
||||
setLocalData(contentType, file, prehandleImageObj, prehandleVideoObj)
|
||||
|
||||
emit('saveLocalMsg', {
|
||||
contentType: contentType,
|
||||
objectId: file.uid,
|
||||
fn: (result) => {
|
||||
msg = result
|
||||
}
|
||||
})
|
||||
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: file.name,
|
||||
fileRawType: file.raw.type,
|
||||
size: file.raw.size
|
||||
fileName: selectedFile.name,
|
||||
fileRawType: selectedFile.raw.type,
|
||||
size: selectedFile.raw.size
|
||||
}
|
||||
const files = { originFile: file.raw }
|
||||
const files = { originFile: selectedFile.raw }
|
||||
|
||||
if (contentType === msgContentType.IMAGE) {
|
||||
requestBody.originWidth = prehandleImageObj.originWidth
|
||||
@@ -99,13 +131,13 @@ const onSelectedFile = async (file) => {
|
||||
requestApi(requestBody, files)
|
||||
.then((res) => {
|
||||
if (res.data.code === 0) {
|
||||
setStoreData(contentType, res.data.data)
|
||||
setStoreData(res.data.data)
|
||||
messageData.updateMsg(msg.sessionId, msg.msgId, {
|
||||
uploadStatus: msgFileUploadStatus.UPLOAD_SUCCESS,
|
||||
uploadProgress: 100
|
||||
})
|
||||
msg.content = JSON.stringify({ type: contentType, value: res.data.data.objectId })
|
||||
emit('sendMessage', { msg })
|
||||
const content = JSON.stringify([{ type: contentType, value: res.data.data.objectId }])
|
||||
emit('sendMessage', { msg, content }) // 上传完成后发网络消息
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -123,19 +155,16 @@ const onSelectedFile = async (file) => {
|
||||
|
||||
/**
|
||||
* 发送的时候设置本地缓存(非服务端数据),用于立即渲染
|
||||
* @param contentType
|
||||
* @param file
|
||||
*/
|
||||
const setLocalData = (contentType, file, prehandleImageObj, prehandleVideoObj) => {
|
||||
const localSrc = URL.createObjectURL(file.raw)
|
||||
const setLocalData = () => {
|
||||
switch (contentType) {
|
||||
case msgContentType.IMAGE:
|
||||
imageData.setImage({
|
||||
objectId: file.uid,
|
||||
objectId: selectedFile.uid,
|
||||
originUrl: localSrc,
|
||||
thumbUrl: localSrc, // 本地缓存缩略图用的是原图
|
||||
fileName: file.name,
|
||||
size: file.raw.size,
|
||||
fileName: selectedFile.name,
|
||||
size: selectedFile.raw.size,
|
||||
thumbWidth: prehandleImageObj.originWidth,
|
||||
thumbHeight: prehandleImageObj.originHeight,
|
||||
createdTime: new Date()
|
||||
@@ -143,18 +172,18 @@ const setLocalData = (contentType, file, prehandleImageObj, prehandleVideoObj) =
|
||||
break
|
||||
case msgContentType.AUDIO:
|
||||
audioData.setAudio({
|
||||
objectId: file.uid,
|
||||
objectId: selectedFile.uid,
|
||||
downloadUrl: localSrc,
|
||||
fileName: file.name,
|
||||
size: file.raw.size
|
||||
fileName: selectedFile.name,
|
||||
size: selectedFile.raw.size
|
||||
})
|
||||
break
|
||||
case msgContentType.VIDEO:
|
||||
videoData.setVideo({
|
||||
objectId: file.uid,
|
||||
objectId: selectedFile.uid,
|
||||
downloadUrl: localSrc,
|
||||
fileName: file.name,
|
||||
size: file.raw.size,
|
||||
fileName: selectedFile.name,
|
||||
size: selectedFile.raw.size,
|
||||
width: prehandleVideoObj.width,
|
||||
height: prehandleVideoObj.height
|
||||
})
|
||||
@@ -162,34 +191,37 @@ const setLocalData = (contentType, file, prehandleImageObj, prehandleVideoObj) =
|
||||
case msgContentType.DOCUMENT:
|
||||
default:
|
||||
documentData.setDocument({
|
||||
objectId: file.uid,
|
||||
documentType: file.raw.type,
|
||||
objectId: selectedFile.uid,
|
||||
documentType: selectedFile.raw.type,
|
||||
downloadUrl: localSrc,
|
||||
fileName: file.name,
|
||||
size: file.raw.size
|
||||
fileName: selectedFile.name,
|
||||
size: selectedFile.raw.size
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端响应数据回来后,设置store缓存
|
||||
* @param contentType
|
||||
* @param file
|
||||
* @param data
|
||||
*/
|
||||
const setStoreData = (contentType, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +240,10 @@ const showRecorder = () => {
|
||||
emit('showRecorder')
|
||||
}
|
||||
|
||||
const showHistory = () => {
|
||||
isShowHistoryDialog.value = true
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
closeWindow
|
||||
})
|
||||
@@ -249,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('功能开发中')"
|
||||
@@ -267,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>
|
||||
@@ -282,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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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.innerWidth屏幕宽度,x要修正一下,往左边弹出菜单
|
||||
222
src/views/message/components/MenuMsgItem.vue
Normal 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>
|
||||
142
src/views/message/components/MenuMsgMain.vue
Normal 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>
|
||||
@@ -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;
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
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'])
|
||||
@@ -13,6 +15,56 @@ const formatSize = computed(() => {
|
||||
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>
|
||||
@@ -24,7 +76,37 @@ onMounted(() => {
|
||||
</span>
|
||||
<div class="footer">
|
||||
<div class="size" :title="formatSize">{{ formatSize }}</div>
|
||||
<a :href="props.url" :download="props.fileName">下载</a>
|
||||
<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>
|
||||
@@ -56,6 +138,54 @@ onMounted(() => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ 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', 'fileName', 'contentType', 'size'])
|
||||
const props = defineProps(['url', 'contentType', 'fileName', 'fileSize', 'use'])
|
||||
const emits = defineEmits(['load'])
|
||||
|
||||
const iconMap = {
|
||||
@@ -28,7 +30,10 @@ const iconMap = {
|
||||
'application/x-7z-compressed': ArchiveIcon,
|
||||
'application/x-tar': ArchiveIcon,
|
||||
'application/gzip': ArchiveIcon,
|
||||
'application/x-bzip2': ArchiveIcon
|
||||
'application/x-bzip2': ArchiveIcon,
|
||||
4: AudioFileIcon,
|
||||
6: VideoFileIcon,
|
||||
7: ArchiveIcon
|
||||
}
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
@@ -36,7 +41,15 @@ const iconComponent = computed(() => {
|
||||
})
|
||||
|
||||
const formatSize = computed(() => {
|
||||
return formatFileSize(props.size)
|
||||
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)
|
||||
@@ -101,13 +114,13 @@ const onDownload = async () => {
|
||||
<span class="extension">{{ iconComponent }}</span>
|
||||
</div>
|
||||
<component v-else :is="iconComponent" />
|
||||
<div class="main">
|
||||
<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 class="download">
|
||||
<div v-if="props.url" class="download">
|
||||
<span
|
||||
v-if="!isDownloading && !isDownloadComplete && !isDownloadError"
|
||||
@click="onDownload"
|
||||
@@ -173,7 +186,6 @@ const onDownload = async () => {
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
|
||||
@@ -5,17 +5,17 @@ import { formatFileSize } from '@/js/utils/common'
|
||||
import { useImageStore } from '@/stores'
|
||||
import ImageloadfailedIcon from '@/assets/svg/imageloadfailed.svg'
|
||||
|
||||
const props = defineProps(['sessionId', 'imgId', 'isForMix', 'thumbWidth', 'thumbHeight'])
|
||||
const props = defineProps(['sessionId', 'imgId', 'isScreenShot', 'thumbWidth', 'thumbHeight'])
|
||||
const emits = defineEmits(['load'])
|
||||
|
||||
const imageData = useImageStore()
|
||||
|
||||
const maxWidth = computed(() => {
|
||||
return props.isForMix ? Math.min(props.thumbWidth, 360) : 360
|
||||
return props.isScreenShot ? Math.min(props.thumbWidth, 360) : 360
|
||||
})
|
||||
|
||||
const maxHeight = computed(() => {
|
||||
return props.isForMix ? Math.min(props.thumbHeight, 270) : 270
|
||||
return props.isScreenShot ? Math.min(props.thumbHeight, 270) : 270
|
||||
})
|
||||
|
||||
const renderWidth = computed(() => {
|
||||
@@ -47,7 +47,7 @@ const url = computed(() => {
|
||||
})
|
||||
|
||||
const imageInSessionSort = computed(() => {
|
||||
const imageList = imageData.imageInSession[props.sessionId]
|
||||
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()
|
||||
@@ -65,11 +65,11 @@ const initialIndex = computed(() => {
|
||||
})
|
||||
|
||||
const fileName = computed(() => {
|
||||
return props.isForMix ? '' : imageData.image[props.imgId]?.fileName
|
||||
return props.isScreenShot ? '' : imageData.image[props.imgId]?.fileName
|
||||
})
|
||||
|
||||
const size = computed(() => {
|
||||
return props.isForMix ? '' : imageData.image[props.imgId]?.size
|
||||
return props.isScreenShot ? '' : imageData.image[props.imgId]?.size
|
||||
})
|
||||
|
||||
const formatSize = computed(() => {
|
||||
@@ -83,6 +83,8 @@ const formatSize = computed(() => {
|
||||
:src="url"
|
||||
:alt="props.imgId"
|
||||
:preview-src-list="srcList"
|
||||
hide-on-click-modal
|
||||
preview-teleported
|
||||
:initial-index="initialIndex"
|
||||
:infinite="false"
|
||||
:lazy="false"
|
||||
@@ -120,7 +122,6 @@ const formatSize = computed(() => {
|
||||
.image-msg-wrapper {
|
||||
display: flex;
|
||||
position: relative;
|
||||
background-color: #000;
|
||||
|
||||
.el-image {
|
||||
width: auto;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'xgplayer/dist/index.min.css'
|
||||
import { formatFileSize } from '@/js/utils/common'
|
||||
import VideoloadfailedIcon from '@/assets/svg/videoloadfailed.svg'
|
||||
|
||||
const props = defineProps(['videoId', 'url', 'fileName', 'size', 'width', 'height'])
|
||||
const props = defineProps(['msgId', 'videoId', 'url', 'fileName', 'size', 'width', 'height'])
|
||||
const emits = defineEmits(['load'])
|
||||
|
||||
const isLoaded = ref(0) // 0未加载,1加载成功,2加载失败
|
||||
@@ -38,7 +38,7 @@ const renderHeight = computed(() => {
|
||||
|
||||
onMounted(() => {
|
||||
const player = new Player({
|
||||
id: `msg-xgplayer-${props.videoId}`,
|
||||
id: `msg-xgplayer-${props.msgId}-${props.videoId}`,
|
||||
url: props.url,
|
||||
fluid: true,
|
||||
autoplay: false,
|
||||
@@ -78,7 +78,11 @@ onMounted(() => {
|
||||
:class="{ loading: isLoaded === 0 }"
|
||||
:style="{ width: `${renderWidth}px`, height: `${renderHeight}px` }"
|
||||
>
|
||||
<div v-show="isLoaded === 1" ref="videoWrapperRef" :id="`msg-xgplayer-${props.videoId}`"></div>
|
||||
<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>
|
||||
|
||||
@@ -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',
|
||||
@@ -217,116 +218,56 @@ const getGroupChatMsgTips = (content) => {
|
||||
|
||||
const showDetailContent = computed(() => {
|
||||
if (isShowDraft.value) {
|
||||
let formatDraft = sessionInfo.value.draft?.replace(/\{\d+\}/g, '[图片]') // 把内容中的`{xxxxxx}`格式的图片统一转成`[图片]`
|
||||
if (sessionInfo.value.sessionType === MsgType.GROUP_CHAT) {
|
||||
formatDraft = formatDraft
|
||||
.split(/(<.*?>)/)
|
||||
.map((item) => {
|
||||
const sliceStr = item.slice(1, -1)
|
||||
const index = sliceStr.indexOf('-')
|
||||
if (index !== -1) {
|
||||
const nickName = sliceStr.slice(index + 1)
|
||||
if (nickName) {
|
||||
return `@${nickName}`
|
||||
} else {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
return formatDraft
|
||||
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) {
|
||||
let 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:
|
||||
//格式化图片内容
|
||||
content = lastMsg.value.content.replace(/\{\d+\}/g, '[图片]')
|
||||
//格式化@内容
|
||||
content = content
|
||||
.split(/(<.*?>)/)
|
||||
.map((item) => {
|
||||
const sliceStr = item.slice(1, -1)
|
||||
const index = sliceStr.indexOf('-')
|
||||
if (index !== -1) {
|
||||
const nickName = sliceStr.slice(index + 1)
|
||||
if (nickName) {
|
||||
return `@${nickName}`
|
||||
} else {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
.join(' ')
|
||||
return getGroupChatMsgTips(content)
|
||||
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 '...'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||