Compare commits

...

180 Commits

Author SHA1 Message Date
Phoenix
efd61b30f4 1231 2025-06-24 16:27:50 +08:00
Phoenix
84096be043 修复 2025-06-24 16:27:00 +08:00
Phoenix
4b7c69ea36 Merge branch 'dev' of http://172.16.100.91:3000/scout666/chat-pc into dev 2025-06-24 16:01:27 +08:00
Phoenix
f5ca14f746 test构建 2025-06-24 16:01:26 +08:00
576e950650 Merge remote-tracking branch 'origin3/yink' into dev 2025-06-24 15:47:08 +08:00
aa3c7e1350 删除lock文件 2025-06-24 15:45:56 +08:00
7a269b0215 删除lock文件 2025-06-24 15:17:57 +08:00
999df303ea Merge branch 'dev' of https://gitea-inner.fontree.cn/scout666/chat-pc into dev 2025-06-24 15:05:15 +08:00
85de430b09 Merge remote-tracking branch 'origin3/yink' into dev 2025-06-24 15:04:58 +08:00
1850ffb727 feat: 增加只支持最近一个月内的聊天消息转发功能 2025-06-24 14:59:07 +08:00
Phoenix
6a94750c05 Merge branch 'xingyy' into dev 2025-06-24 14:51:21 +08:00
Phoenix
acc8aeed2c fix(auth): 更新默认访问令牌以解决过期问题 2025-06-24 14:24:43 +08:00
Phoenix
1894bee556 关闭登录失效提示 2025-06-24 14:06:30 +08:00
f808c018fd Merge remote-tracking branch 'origin3/main' into yink 2025-06-24 13:56:58 +08:00
Phoenix
b101831c53 12 2025-06-23 17:01:57 +08:00
Phoenix
6791da7d8e 12 2025-06-23 16:44:15 +08:00
Phoenix
4cf5e8ce18 chore: 更新 @vitejs/plugin-vue 依赖至 5.2.4 版本
升级 @vitejs/plugin-vue 以兼容最新 vite 和 vue 版本要求
2025-06-23 16:39:58 +08:00
ace9b39fe3 Merge pull request 'feat: 6.23提测' () from yink into dev
Reviewed-on: 
2025-06-23 08:06:34 +00:00
0111453f06 feat: 6.23提测 2025-06-23 14:53:03 +08:00
Phoenix
f876ee7bbe Merge branch 'xingyy' into dev 2025-06-20 10:10:23 +08:00
Phoenix
db8621ec5c fix(editor): 修复@提及列表显示逻辑和全体成员选项
修复@提及列表中全体成员选项的显示逻辑,不再需要管理员权限即可显示。同时优化点击事件处理,当点击非@提及列表区域时自动隐藏列表。

feat(message): 增加文件预览类型检查和样式优化

添加文件类型预览支持检查,仅允许预览PDF、Excel、Word和PPT文件。优化文件消息的悬停样式,提升用户体验。
2025-06-20 10:09:54 +08:00
07c3808122 Merge branch 'wyfMain-dev' into dev 2025-06-20 09:13:43 +08:00
d0dd83451c 处理指定消息定位跳转部分问题,现在除了没有任何会话的情况下跳转,会有偏移,其他情况都基本正常 2025-06-18 17:11:30 +08:00
Phoenix
b28c288665 fix: 修复多选删除未选择时的提示和群聊@功能限制
修复多选删除时未选择聊天记录未提示的问题,限制@功能仅在群聊中可用
优化会话菜单选项文字描述,移除无用代码
2025-06-13 11:05:35 +08:00
Phoenix
ca958bb2cb fix(auth): 更新默认访问令牌以修复安全漏洞 2025-06-12 14:20:50 +08:00
Phoenix
fd9a5555dc style(office): 移除编辑器配置中的多余空行 2025-06-12 10:05:07 +08:00
Phoenix
7733f88dae Merge branch 'xingyy' into dev 2025-06-11 16:55:25 +08:00
Phoenix
1a85e9d13e 编辑器优化 2025-06-11 16:54:54 +08:00
Phoenix
bab907a1e2 fix: 修复未读消息数量显示重复问题并移除调试日志
修复TalkItem.vue中未读消息数量显示重复的问题
移除MultiSelectFooter.vue中无用的调试日志打印
2025-06-11 15:07:20 +08:00
Phoenix
a506b4dcc1 Merge branch 'dev' of http://172.16.100.91:3000/scout666/chat-pc into dev 2025-06-11 14:48:09 +08:00
Phoenix
45e4415cec fix: 修复消息转发、上传和编辑器引用删除功能
- 添加ChatMsgTypeForward到可转发消息类型
- 修复请求拦截器中状态码判断逻辑
- 优化视频消息上传封面获取和预览显示
- 修复上传分片错误处理和进度更新
- 重构编辑器引用删除逻辑,提升代码可维护性
- 调整图片消息样式和上传蒙版显示
2025-06-11 14:47:13 +08:00
Phoenix
57e4ba69d9 refactor: 统一错误消息处理并优化编辑器功能
- 将错误消息处理移至请求拦截器统一处理
- 优化编辑器提及功能,过滤当前用户
- 清理编辑器相关冗余代码和注释
- 改进空消息检测逻辑
2025-06-11 11:39:11 +08:00
Phoenix
88bbf16699 fix(editor): 修复编辑器空内容判断和换行处理问题
改进编辑器空内容检测逻辑,确保更准确地判断是否为空内容
重构换行处理逻辑,使用辅助函数插入换行符并保持光标位置
优化消息发送前的空内容检查,防止发送无效消息
2025-06-11 11:20:15 +08:00
Phoenix
d46ced7614 style(消息组件): 移除系统消息的不可选中和悬停样式
移除系统消息组件中不必要的 user-select 属性和链接的悬停效果,保持样式简洁
2025-06-11 10:25:59 +08:00
Phoenix
044617580c fix(群组消息): 移除用户信息弹窗的点击事件
移除多个群组系统消息组件中用户名的点击事件,这些事件原本会触发用户信息弹窗
2025-06-11 10:23:42 +08:00
Phoenix
54a46e2fb4 refactor(editor): 优化编辑器代码结构并清理注释 2025-06-11 09:51:16 +08:00
Phoenix
28938aba66 refactor(消息面板): 重构消息撤回逻辑,提取公共函数
将消息撤回的条件判断逻辑提取为独立函数 canAddRevokeOption
简化主逻辑代码,提高可读性和可维护性
2025-06-11 09:44:17 +08:00
Phoenix
8e645226b8 fix(消息面板): 添加普通用户撤回消息的条件限制
当用户不是管理员时,只有在撤回时间限制内且消息是自己发送的情况下才显示撤回选项
2025-06-11 09:39:49 +08:00
97f05d2c5c Merge branch 'main' into dev 2025-06-10 19:53:14 +08:00
d6782d867c 1、会话列表增加群人数显示;2、按文件类型搜索时,文件支持点击预览;3、搜索聊天记录时,文件类型和图片类型支持直接显示出来,并可以点击预览 2025-06-10 19:51:31 +08:00
8d73e0d48b Merge branch 'main' into dev 2025-06-10 17:39:31 +08:00
f9d02d67a6 修改撤回、群公告、转发等类型消息的展示逻辑 2025-06-10 17:36:03 +08:00
Phoenix
4b5c160e94 Merge branch 'dev' of http://172.16.100.91:3000/scout666/chat-pc into dev 2025-06-10 15:03:31 +08:00
Phoenix
ebd567a757 fix(消息面板): 修复消息菜单和撤回消息按钮的显示逻辑
修复消息菜单中缺少的is_self_action属性设置,确保撤回消息按钮仅在自身操作时显示
调整编辑器内容处理逻辑,优化草稿保存的数据结构
2025-06-10 15:03:29 +08:00
18871db6b6 Merge branch 'main' into dev 2025-06-10 14:56:10 +08:00
5f4dd80b2a 解决es搜索时按人搜索和按群搜索的跳转不正确问题;解决按日期搜索后切换其他条件或取消没有正确重置日期选择问题 2025-06-10 14:50:15 +08:00
Phoenix
1ae317dbb3 Merge branch 'xingyy' into dev 2025-06-10 13:39:29 +08:00
Phoenix
e4354d42cd feat(消息面板): 添加dayjs依赖并优化消息撤回时间计算
使用dayjs替换原有的日期处理逻辑,提高代码可读性并延长消息撤回时间至5分钟
2025-06-10 13:28:54 +08:00
Phoenix
8bba2d64af fix(editor): 优化提及插入逻辑并修复光标位置问题
重构提及插入逻辑,使用更直接的方式删除@符号到光标间的内容
将普通空格替换为不间断空格以避免被HTML压缩
确保光标始终正确放置在插入内容之后
2025-06-10 11:03:24 +08:00
Phoenix
d4e52152ef feat(editor): 添加鼠标点击选择mention功能并优化插入逻辑
- 新增handleMentionSelectByMouse函数处理鼠标点击选择mention
- 重构insertMention函数,支持传入range参数并优化插入逻辑
- 修复mention列表点击事件,防止默认行为导致的问题
- 优化onSubscribeMention函数,确保焦点和选区正确处理
2025-06-10 09:45:10 +08:00
Phoenix
bdf07155c8 fix(editor): 修复提及功能中用户ID处理问题
修复提及成员时用户ID类型转换问题,确保ID统一为字符串类型。同时为管理员添加"全体成员"提及选项,并完善提及列表的数据处理逻辑。
2025-06-09 16:48:52 +08:00
Phoenix
b905db0cfa fix: 优化消息撤回逻辑和编辑器内容处理
- 调整消息菜单的撤回选项显示逻辑,区分单聊和群聊场景
- 修复编辑器内容处理,使用trimEnd替代trim避免尾部空格问题
- 移除重复的quote元素删除操作
- 优化编辑器空内容判断逻辑
2025-06-09 15:29:24 +08:00
Phoenix
3b6d998ce1 Merge branch 'xingyy' into dev 2025-06-09 14:46:59 +08:00
Phoenix
5340461a7e fix(utils): 修复wujie环境下剪贴板功能兼容性问题
修改clipboardImage方法以支持wujie微前端环境,使用主应用的navigator.clipboard对象
同时优化canvas图片绘制参数,确保图片缩放正确
2025-06-09 14:46:33 +08:00
Phoenix
45eec2ff22 Merge branch 'dev' of http://172.16.100.91:3000/scout666/chat-pc into dev 2025-06-09 13:57:38 +08:00
Phoenix
9c34066128 Merge branch 'xingyy' into dev 2025-06-09 13:57:32 +08:00
Phoenix
628894a254 refactor(editor): 优化mention处理逻辑并移除调试日志
移除调试用的console.log语句
重构mention列表过滤逻辑,使用startsWith替代includes
添加Backspace和Delete键删除mention元素的功能
优化键盘事件处理逻辑,减少不必要的DOM操作
2025-06-09 13:57:15 +08:00
92fce58429 Merge branch 'main' into dev 2025-06-09 13:33:02 +08:00
d55616e2e7 Merge branch 'wyfMain-dev' 2025-06-09 13:32:31 +08:00
031411ba49 更换转发、es搜索接口为/v2;处理搜索判断是否还有更多数据的逻辑;解决聊天记录中视频类型点击播放会全屏关不掉问题;更换鼠标悬浮背景色;新增列表中省略内容悬浮显示完整功能;修改回到聊天底部功能实现 2025-06-09 13:29:06 +08:00
Phoenix
c0f4248385 Merge branch 'main' of http://172.16.100.91:3000/scout666/chat-pc 2025-06-09 11:52:12 +08:00
Phoenix
2e998a1174 Merge branch 'dev' of http://172.16.100.91:3000/scout666/chat-pc into dev 2025-06-09 11:51:48 +08:00
Phoenix
60a2fb996b Merge branch 'xingyy' into dev 2025-06-09 11:51:38 +08:00
Phoenix
047cea20b9 refactor(editor): 将工具栏配置从reactive改为ref并调整表情组件样式
将CustomEditor中的navs从reactive改为ref以提高性能
调整MeEditorEmoticon的样式,包括间距改为内边距、添加悬停背景色,并移除表情缩放效果
2025-06-09 11:44:57 +08:00
Phoenix
7fea56f704 refactor(editor): 优化编辑器输入处理逻辑和性能
- 重构输入事件处理函数,减少不必要的DOM操作
- 简化键盘事件处理逻辑,移除冗余日志
- 优化消息发送逻辑,增加内容检查
- 改进引用元素处理,增强交互体验
- 统一表情处理逻辑,使用switch语句替代if-else
- 优化草稿保存和加载机制,使用DocumentFragment提高性能
- 清理冗余代码和注释,保持代码简洁
2025-06-09 11:37:39 +08:00
b282562cdd Merge branch 'main' into dev 2025-06-06 18:54:59 +08:00
642992640f 解决1、清空群公告提示文字和UI不一致问题;2、去除聊天记录搜索选中类别后,输入框的输入提示;3、解决点击关闭按钮,关闭通讯录弹窗的时候没有重置窗口内条件选择问题 2025-06-06 18:52:40 +08:00
Phoenix
3ec981ea7f fix: 修复文件上传和编辑器相关问题
- 启用vueDevTools插件用于开发调试
- 移除调试用的console.error/log语句
- 修复文件扩展名获取可能导致的错误
- 优化文件上传逻辑,添加path字段
- 重构编辑器图片上传处理,支持直接发送
- 调整编辑器样式颜色
2025-06-06 16:57:02 +08:00
Phoenix
7067c42b2b feat(editor): 添加Ionicons4图标并优化编辑器功能
- 新增@vicons/ionicons4依赖用于编辑器发送按钮
- 优化提及列表滚动行为,保持选中项可见
- 支持Ctrl+Enter/Shift+Enter换行功能
- 添加发送按钮和编辑器placeholder提示
- 修复引用消息id字段不一致问题
2025-06-06 14:49:38 +08:00
Phoenix
1ff26564c7 refactor(editor): 优化引用消息的点击事件处理逻辑
使用事件委托统一处理引用消息的点击事件,包括关闭按钮点击和光标定位
移除重复的事件监听器,简化代码结构
修复引用消息ID字段从msg_id改为id的匹配问题
2025-06-06 13:43:39 +08:00
Phoenix
b18a6e5432 feat(编辑器): 添加清除事件常量并优化提及功能
在事件总线常量中添加 editor:clear 事件类型
优化提及功能,确保编辑器获得焦点后光标位置正确
2025-06-06 12:00:12 +08:00
Phoenix
17c1368346 feat: 优化消息发送逻辑和编辑器功能
- 在vite配置中启用vueDevTools工具
- 重构PanelFooter.vue中的图片消息发送逻辑,改为直接调用onSendMessage
- 修改CustomEditor.vue的消息发送逻辑,支持分类型处理消息内容
- 增加编辑器引用元素的检查逻辑,避免无效引用
- 优化图片上传后的URL替换逻辑,确保编辑器内容更新
2025-06-06 11:52:55 +08:00
Phoenix
f279248a51 feat(编辑器): 增强编辑器功能并优化图片处理
- 添加对粘贴图片的支持,自动触发上传流程
- 优化图片插入逻辑,保留原始尺寸信息并改进显示效果
- 重构消息内容解析逻辑,完善数据结构
- 移除冗余的文件插入功能,专注于图片处理优化
- 调整编辑器样式,改进图片显示效果
2025-06-06 10:44:17 +08:00
d0abf7d8ab Merge branch 'main' into dev 2025-06-06 09:05:56 +08:00
5b4ee3c677 处理代码冲突——恢复已读未读功能 2025-06-05 16:32:31 +08:00
Phoenix
c89056d7f1 edit 2025-06-05 16:21:39 +08:00
Phoenix
409af72039 Merge branch 'dev' of http://172.16.100.91:3000/scout666/chat-pc into dev 2025-06-05 14:14:47 +08:00
Phoenix
799599bd83 Merge branch 'xingyy' into dev 2025-06-05 14:14:38 +08:00
Phoenix
ef0eb903a7 feat(消息组件): 优化图片消息上传体验并修复文件扩展名获取
1. 在图片消息组件中添加上传进度显示和加载状态
2. 重构图片上传逻辑,先显示本地预览再上传
3. 修复文件消息组件中从文件名获取扩展名改为从文件路径获取
4. 根据消息浮动方向调整提及文本颜色

重构了图片上传流程,现在会先显示本地预览图片,然后在上传过程中显示进度条。同时修复了文件扩展名获取逻辑,现在从文件路径而非文件名获取扩展名。优化了提及文本的颜色显示,使其根据消息浮动方向(左/右)显示不同颜色。
2025-06-05 14:13:50 +08:00
Phoenix
f511244d59 Merge branch 'xingyy'
# Conflicts:
#	src/views/message/inner/panel/PanelContent.vue   resolved by xingyy version
2025-06-05 11:42:46 +08:00
Phoenix
a5a70391a4 feat: 添加ERP用户ID字段并优化多处功能
- 在ISession接口中添加erp_user_id字段以支持ERP系统集成
- 更新.env.test环境变量添加VITE_PAGE_URL配置
- 启用vueDevTools插件用于开发调试
- 移除好友删除选项以限制用户操作
- 优化消息发送逻辑,过滤空白内容
- 调整头像右键菜单触发条件
- 更新文件消息打开链接使用环境变量
- 修改会话菜单中用户信息跳转使用erp_user_id
- 更新默认access token值
2025-06-05 11:41:06 +08:00
ec18d85546 Merge branch 'wyfMain-dev' into dev 2025-06-05 09:20:27 +08:00
928c4f91f9 Merge branch 'wyfMain-dev' 2025-06-05 09:20:03 +08:00
cb41751b86 解决单聊的已读未读也会点击打开已读详情的问题 2025-06-05 09:19:17 +08:00
Phoenix
a97f293a6c Merge branch 'xingyy' into dev
# Conflicts:
#	src/views/message/inner/panel/PanelFooter.vue   resolved by xingyy version
2025-06-04 16:32:24 +08:00
Phoenix
2518f77f9c Merge branch 'main' of http://172.16.100.91:3000/scout666/chat-pc
# Conflicts:
#	src/views/message/inner/panel/PanelFooter.vue   resolved by main version
2025-06-04 16:31:29 +08:00
Phoenix
d4a1fd5c8b fix(vite): 移除开发环境下vueDevTools的配置
由于开发工具配置错误导致开发环境启动问题,暂时注释掉vueDevTools插件配置
2025-06-04 16:30:54 +08:00
Phoenix
1314f8063c refactor: 移除未使用的SimpleEditorExample组件并更新默认token
移除PanelFooter.vue中未使用的SimpleEditorExample组件导入以清理代码
更新auth.js中的默认token值以使用新的测试凭证
2025-06-04 16:29:02 +08:00
dbdec912ce 接入有人@你功能,并实现热更新;接入群信息更新、双场景下的群聊解散、退群消息更新群聊设置按钮点击权限 2025-05-31 03:53:18 +08:00
d5b0a8b599 处理搜索点击更多聊天记录携带搜索文本;处理点击跳转到聊天位置时自动关闭搜索框;处理打开更多搜索框时自动关闭下拉列表;处理首页搜索框样式 2025-05-29 18:51:27 +08:00
e27682badf 处理代码合并后的问题 2025-05-29 18:28:12 +08:00
a86cdbf94a Merge branch 'wyfMain-dev' 2025-05-29 18:10:46 +08:00
e1e11b7633 完成搜索框中点击更多通讯录、更多群聊、点击通讯录、群聊、聊天记录对应的跳转,并处理加载更多场景;处理跳转到指定搜索记录的功能,并实现向上向下加载更多数据,并保持滚动位置 2025-05-29 18:04:16 +08:00
331ca65db6 完成新版已读回执规则接入,处理相应的socket消息监听、相关已读未读数量统计和详情列表展示 2025-05-28 15:40:36 +08:00
Phoenix
2c1ae41c3e feat(theme): 将主色调从#1890ff更改为#462AA0
统一修改多处UI组件的主色调,从蓝色(#1890ff)变更为紫色(#462AA0),以保持视觉一致性。同时优化了文件上传逻辑和滚动到底部功能。

refactor(dom): 提取滚动相关操作为工具函数
将滚动到底部逻辑封装为可复用的工具函数,并在多处调用位置进行替换,提高代码复用性。

fix(upload): 修复上传中文件点击打开问题
增加上传状态判断,避免在上传过程中点击文件时打开新窗口。

chore(deps): 更新依赖包版本
升级@types/node和watchpack等依赖包版本。
2025-05-28 11:29:13 +08:00
bdfd604fd9 接入会话置顶、会话免打扰功能到聊天设置页面;接入退出群聊、解散群聊功能,并解决历史遗留问题:群主不能退群(普通群);接入新版socket消息监听用于处理消息已读回执,读消息视角依然沿用旧版轮询方案防止丢失既有数据;查视角采用新版监听socket消息方案代替轮询接口实现 2025-05-27 18:49:48 +08:00
Phoenix
44a1dd0986 fix: 修复用户信息显示逻辑
- 在ForwardRecord.vue中,将用户ID的引用从item.user_id更改为item.erp_user_id,以确保正确显示用户信息。
2025-05-27 11:48:18 +08:00
Phoenix
8ce7d143ce 12 2025-05-27 11:43:58 +08:00
Phoenix
58b70f84d7 1 2025-05-27 11:42:34 +08:00
Phoenix
6d663d3d01 Merge branch 'main' of http://172.16.100.91:3000/scout666/chat-pc 2025-05-27 11:38:24 +08:00
Phoenix
b117765bdc fix: 修复用户信息处理和组件逻辑
- 在useSessionMenu.ts中,修正用户信息处理逻辑,使用正确的用户ID
- 在IndexSider.vue中,移除不必要的watch监听器,简化代码
- 在PanelContent.vue中,优化右键菜单逻辑,确保仅在特定条件下触发
2025-05-27 11:38:22 +08:00
1edb639ad9 Merge branch 'wyfMain-dev' 2025-05-27 11:24:42 +08:00
8ecee15180 完成聊天记录按日期搜索功能 2025-05-27 11:21:55 +08:00
Phoenix
e3f2346d66 fix: 更新配置和组件逻辑
- 在Editor.vue中,优化成员列表的渲染逻辑,确保在特定条件下显示'所有人'选项
- 在FileMessage.vue中,修改上传进度显示条件,避免在未开始上传时显示进度圆环
- 在uploads.ts中,简化重试上传逻辑,移除不必要的暂停状态检查
- 在PanelContent.vue中,添加对重试操作的支持,确保能够针对特定项目进行重试
2025-05-27 11:20:55 +08:00
efb410b657 再次重构从聊天app接入的按条件查询聊天记录组件,去除了按群成员查询、按日期查询的特异化,统一调用history接口,根据不同的场景处理参数,并处理空页面和分页等。处理不同交互场景下需要重置搜索条件的情况。目前按日期查询待接入,其他已完成 2025-05-26 18:57:02 +08:00
Phoenix
c91a70f86d 12 2025-05-26 17:00:09 +08:00
Phoenix
02ba7af6eb Merge branch 'main' into xingyy 2025-05-26 16:59:50 +08:00
Phoenix
19a6c89b76 fix: 修复上传进度显示和重试逻辑问题
- 在FileMessage.vue中,仅当上传进度大于0时显示进度圆环,避免初始状态显示
- 在PanelContent.vue中,为retry函数添加参数传递,确保重试操作针对特定项目
- 在uploads.ts中,添加暂停状态检查并处理上传失败时的进度回调
2025-05-26 16:58:12 +08:00
Phoenix
e2e0a3ea3a fix: 修复文件上传逻辑和UI问题
- 修复文件上传暂停/恢复逻辑错误,调整播放状态与上传动作的对应关系
- 为视频上传添加半透明蒙层提升用户体验
- 移除上传管理中的冗余字段和注释代码
- 调整确认框标题的padding样式
- 添加消息重发确认功能
2025-05-26 16:43:11 +08:00
Phoenix
5bda2be585 更新.env.test文件中的API地址,调整FileMessage.vue组件的样式,增加高度和灵活性,优化auth.js中的token获取逻辑,增强MultiSelectFooter.vue中的批量删除功能,添加确认框提示。 2025-05-26 12:00:30 +08:00
Phoenix
57f169ca78 12 2025-05-22 15:44:25 +08:00
Phoenix
470da9e7b7 1 2025-05-22 15:27:19 +08:00
Phoenix
c7df773b97 Merge branch 'main' of http://172.16.100.91:3000/scout666/chat-pc 2025-05-22 15:26:49 +08:00
Phoenix
b7ae8598b4 更新组件声明,新增SearchByCondition组件;调整FileMessage.vue的样式,增加高度;优化ImageMessage.vue中的图片样式,移除不必要的样式属性。 2025-05-22 15:26:48 +08:00
69e95e5c4d Merge branch 'wyfMain-dev' 2025-05-22 15:25:59 +08:00
6517c082d5 处理指定聊天记录的定位跳转,查找上下文【不稳定】 2025-05-22 15:24:13 +08:00
Phoenix
cba7e9205e git忽略 2025-05-22 15:10:20 +08:00
Phoenix
9487ae526b Merge branch 'main' of http://172.16.100.91:3000/scout666/chat-pc
# Conflicts:
#	src/store/modules/dialogue.js   resolved by main version
#	src/utils/auth.js   resolved by main version
2025-05-22 15:09:16 +08:00
Phoenix
e3d61107cb Merge branch 'xingyy'
# Conflicts:
#	env/.env.test   resolved by xingyy version
2025-05-22 15:08:36 +08:00
Phoenix
db599dadb9 优化文件消息组件的下载逻辑,新增群组信息获取功能,调整对话存储以支持群组信息 2025-05-22 15:07:27 +08:00
Phoenix
89f707a031 优化ForwardRecord.vue中的消息文本背景色,新增UserCardModal.vue的离开事件处理逻辑,调整useTalkRecord.ts中的数据加载日志,更新SkipBottom.vue中的滚动到底部逻辑,增强用户体验。 2025-05-22 11:52:44 +08:00
Phoenix
46644626e7 修改项目名称为“IM”,更新相关文件中的标题和版权信息,优化文件下载功能,调整部分组件的显示内容。 2025-05-22 10:30:02 +08:00
Phoenix
0fe1119789 新增OnlyOffice文档编辑器和Fluent图标库依赖,优化文件消息组件的下载功能,调整文本消息背景色,改进会话菜单逻辑,优化会话列表排序,修复部分样式问题。 2025-05-21 19:57:07 +08:00
Phoenix
91107e2f85 优化OnlyOffice文档加载逻辑,移除重复脚本加载检查,新增获取URL参数的功能,调整文档ID生成方式,确保用户ID唯一性。 2025-05-21 13:27:33 +08:00
Phoenix
579fed2e69 更新组件声明,新增NDropdown支持;调整ForwardRecord.vue中的模态框逻辑,优化文件点击事件处理,修复ContactModal.vue中的数据提交逻辑,更新路由配置以支持新页面。 2025-05-21 13:18:41 +08:00
0ab2ce814a 解决搜索聊天记录组件搜索时,改变搜索关键字没有正确调用接口、处理搜索结果显示等问题 2025-05-21 11:41:03 +08:00
41dbb8c872 处理解散群聊、退出群聊按钮的可视权限;使用naiveUI的infiniteScroll组件代替overflowY,并用自带的load事件代替v-loadmore指令,重新整理复用的搜索列表组件,整理传参、数据处理等,解决参数混乱、数据处理混乱问题 2025-05-20 19:58:16 +08:00
8694921f25 完成清空聊天记录功能;接入群信息修改中的编辑群名称功能与相关交互,并调整群聊设置弹窗的样式;去除旧版群公告入口 2025-05-20 18:02:12 +08:00
Phoenix
b65f38f02e 更新组件声明,移除SearchByCondition组件支持;调整FileMessage.vue中的文件点击事件处理逻辑,优化SysGroupAdminMessage.vue中的用户信息展示逻辑;修复auth.js中的token获取逻辑。 2025-05-20 15:32:20 +08:00
62f5b458a5 Merge branch 'wyfMain-dev' 2025-05-20 15:07:12 +08:00
2439562838 完成群聊设置中群公告模块的接入和重构 2025-05-20 15:05:57 +08:00
Phoenix
df80cd031e Merge branch 'xingyy'
# Conflicts:
#	src/utils/auth.js
2025-05-20 14:26:58 +08:00
Phoenix
f1b802cde8 更新依赖版本,主要将vite升级至6.3.5,并调整相关插件的版本;在多个组件中添加了euid属性以支持用户信息展示;修复了token获取逻辑,优化了用户信息模态框的显示逻辑。 2025-05-20 14:23:50 +08:00
Phoenix
846031a5cb 更新组件声明,新增NTag支持;调整ContactModal.vue中的头像模块,优化联系人选择逻辑,改善会话记录显示;注释掉vite.config.ts中的vueDevTools配置。 2025-05-20 10:21:01 +08:00
cecca6df9c 新增群公告空页面和编辑页面,并处理对应的状态切换按钮 2025-05-19 19:56:28 +08:00
19e4954484 Merge branch 'wyfMain-dev' 2025-05-19 18:13:18 +08:00
115a3f1f10 修改单聊群聊的聊天设置显示,并完成重构群成员部分交互样式;重构从聊天APP接入的按分类搜索历史聊天记录组件,处理文件类型、图片视频类型显示,接入naive—UI的日期选择组件 2025-05-19 18:09:18 +08:00
Phoenix
23415808bb 提交 2025-05-19 16:56:52 +08:00
Phoenix
73063d1faf 更新头像模块,新增群聊类型标签显示功能,优化标签样式计算逻辑;调整视频消息组件,注释掉未使用的图像处理函数;更新对话存储,添加头像和群类型字段;修复消息视图中的头像显示逻辑。 2025-05-19 13:52:41 +08:00
Phoenix
ae23e0a1d1 更新组件声明,移除NDrawer和NDrawerContent,新增SearchByCondition组件支持;调整FileMessage.vue中的文件名样式,设置高度为50px;修改IndexSider.vue和PanelHeader.vue中的注释 2025-05-19 11:39:20 +08:00
Phoenix
c93023effa Merge branch 'main' of http://172.16.100.91:3000/scout666/chat-pc
# Conflicts:
#	src/utils/auth.js
2025-05-19 10:53:11 +08:00
3eaac91ba8 Merge branch 'wyfMain-dev' 2025-05-19 09:06:53 +08:00
9360ecaaf9 调整聊天记录弹窗标题 2025-05-19 09:04:28 +08:00
Phoenix
edec2753ba 调整TextMessage.vue组件中的emoji样式,设置宽度和高度为22px,以改善消息显示效果。 2025-05-16 19:55:08 +08:00
Phoenix
b5ccba9899 更新组件声明,新增HighLightText、NDrawer、NDrawerContent、SearchItem和SearchList支持;调整ContactModal.vue中的状态管理,优化MultiSelectFooter.vue中的联系人选择逻辑和样式;在main.ts中引入naive-ui调整样式;修复auth.js中的token获取逻辑。 2025-05-16 19:49:57 +08:00
c39d5aea88 接入查询当前用户所在群聊列表接口,并完成整个通讯录模块的交互和样式,包括员工通讯录和群聊列表;并完成对应的聊天会话跳转;新增按条件搜索组件,等待接入历史记录的按条件查询; 2025-05-16 17:00:28 +08:00
Phoenix
b04d25a243 在package.json和pnpm-lock.yaml中添加viewerjs库,版本为1.11.7,以支持图像查看功能。同时删除了x-preview-img组件的相关代码。 2025-05-16 16:49:04 +08:00
Phoenix
9e31271cc3 在package.json和pnpm-lock.yaml中添加sortablejs库,版本为1.15.6,以支持拖拽排序功能。 2025-05-16 16:24:39 +08:00
Phoenix
6d08dbe42f Merge branch 'main' of http://172.16.100.91:3000/scout666/chat-pc
# Conflicts:
#	src/utils/auth.js   resolved by origin/main(远端) version
#	src/views/message/inner/IndexSider.vue   resolved by origin/main(远端) version
2025-05-16 16:21:21 +08:00
Phoenix
478336c2fe 更新组件和API,新增语音转文本功能,优化音频消息组件,调整右键菜单逻辑 2025-05-16 15:20:35 +08:00
Phoenix
419bde4db2 更新编辑器功能,新增编辑消息事件处理,优化撤回消息组件,调整相关事件总线 2025-05-16 11:32:07 +08:00
Phoenix
fca127b42b 更新组件,新增NCheckbox支持,删除不必要的图标文件,优化ContactModal.vue和MultiSelectFooter.vue中的逻辑 2025-05-16 09:48:35 +08:00
814eb44358 新增历史聊天记录查询弹窗;接入erp的v-loadmore指令,用于实现PC端的触底加载更多;继续优化搜索模块的联动逻辑 2025-05-15 18:56:41 +08:00
Phoenix
94cf0f9f63 更新ContactModal.vue组件,添加NRadio和NVirtualList支持,优化联系人选择逻辑,调整搜索过滤器,改进用户界面和交互体验。 2025-05-15 18:32:34 +08:00
Phoenix
fad84e5bf3 更新组件和API,新增NImage支持,优化文件上传功能,调整主题颜色,删除不必要的图片,改进用户界面和交互体验。 2025-05-15 16:07:56 +08:00
ed0737b5e3 修改聊天设置弹窗,新增单聊的聊天设置;新增搜索聊天记录的弹窗,接入部分接口数据,调整样式 2025-05-14 17:22:25 +08:00
Phoenix
661472a70a 更新组件和API,添加NProgress和NTag支持,优化上传功能,增强编辑器功能,调整样式和结构,提升用户体验。 2025-05-14 11:50:52 +08:00
701d878f7d 接入聊天APP的全局搜索组件,包括搜索列表、搜索项和高亮等 2025-05-13 19:05:42 +08:00
7544b3d324 调整通讯录中员工通讯录的按姓名搜索对应人员功能,调整对应样式 2025-05-13 14:08:20 +08:00
8c9f634d0b 完成通讯录功能的员工通讯录tab,接入对应的接口、交互组件等 2025-05-12 17:00:56 +08:00
Phoenix
651baafd0f 更新package.json和pnpm-lock.yaml以添加unplugin-auto-import和unplugin-vue-components依赖,同时在vite.config.ts中集成这两个插件以支持自动导入和组件解析。调整MeEditorEmoticon.vue和UserCardModal.vue中的样式和结构,优化用户界面。 2025-05-12 16:54:04 +08:00
Phoenix
c9794c3f25 Merge branch 'main' into xingyy 2025-05-12 14:35:50 +08:00
Phoenix
067312cd5c 更新vite.config.ts以集成compressPlugin和vueDevTools插件,同时调整MeEditorEmoticon.vue中的表情图标尺寸,优化样式。 2025-05-12 14:35:15 +08:00
a82875da05 Merge branch 'wyfMain-dev' 2025-05-12 14:30:21 +08:00
9bd1bdadb2 新增默认token用于直接启动项目时调用接口;注释vueDevTools的大图标遮挡,使用时按需放开即可 2025-05-12 14:29:01 +08:00
Phoenix
51a406e5e5 test 2025-05-12 14:23:09 +08:00
Phoenix
fed311c76e Merge branch 'main' into xingyy 2025-05-12 14:18:18 +08:00
Phoenix
7895ff81c8 提交 2025-05-12 14:18:01 +08:00
Phoenix
b84430a7e3 Merge branch 'main' into xingyy
# Conflicts:
#	src/constant/theme.ts   resolved by xingyy version
#	src/main.ts   resolved by xingyy version
#	uno.config.ts   resolved by xingyy version
2025-05-12 14:12:26 +08:00
b35243bb79 Merge branch 'wyfMain-dev' 2025-05-12 13:59:58 +08:00
43541a1187 迁入ERP的组织结构树组件,作为通讯录的组织结构;接入相关依赖组件,调整通讯录入口及相关样式 2025-05-12 13:55:14 +08:00
Phoenix
d413a6b9fe 更新了多个文件以优化样式和功能,包括在UnoCSS配置中添加预设,调整了主题颜色和消息组件的样式,更新了环境变量,修复了头像组件的尺寸,并在消息面板中添加了多选功能。 2025-05-12 13:44:13 +08:00
d021415568 导入ERP的表格组件和modal组件的简单代理,并封装含业务的自定义modal和自定义button组件;接入新加入的消息类型;调整群聊设置中部分内容显示 2025-05-09 16:47:11 +08:00
Phoenix
d2c8de16bb 在IndexSider.vue中添加了调试输出,更新了TalkItem.vue以使用新的头像组件并调整了样式,增加了TalkItem的高度。 2025-05-09 14:58:59 +08:00
Phoenix
7717fe1fb3 在package.json中添加了"type": "module"字段,并在devDependencies中引入了"vite-plugin-vue-devtools"。同时,更新了pnpm-lock.yaml以反映这些更改,并在vite.config.ts中集成了vueDevTools插件以支持开发工具。 2025-05-09 11:17:09 +08:00
903ae24458 Merge branch 'main' into wyfMain-dev 2025-05-09 10:58:37 +08:00
a80c52475e 修改群聊设置布局样式 2025-05-09 10:56:56 +08:00
8549ca6b54 解决项目启动异常问题,更新node版本号 2025-05-09 10:34:48 +08:00
Phoenix
9f63dbfe27 更新获取访问令牌的逻辑,从localStorage中解析token,若不存在则返回默认值。 2025-05-09 10:08:51 +08:00
Phoenix
c695529217 删除多个环境配置文件、ESLint配置文件及Electron主进程和预加载脚本,更新README文档以反映项目功能和结构,修改Vite配置以集成UnoCSS,调整主布局样式以优化界面显示。 2025-05-09 09:43:00 +08:00
166 changed files with 15652 additions and 9300 deletions
.env.env.electron.eslintrc.cjs.gitignoreREADME.md
electron
env
index.htmlpackage.jsonpnpm-lock.yaml
src
App.vue
api
assets
components

8
.env
View File

@ -1,8 +0,0 @@
ENV = 'development'
VITE_BASE=/
VUE_APP_PREVIEW=false
VITE_BASE_API=http://172.16.100.93:8503
VITE_EPR_BASEURL=http://114.218.158.24:9020
VITE_SOCKET_API=ws://172.16.100.93:8504
VUE_APP_WEBSITE_NAME="Lumen IM"

View File

@ -1,6 +0,0 @@
ENV = 'production'
VITE_BASE=./
VITE_ROUTER_MODE=hash
VITE_BASE_API=https://xxx.xxx.com
VITE_SOCKET_API=wss://xxx.xxx.com

View File

@ -1,23 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
env: {
node: true // 只需将该项设置为 true 即可
},
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': 'off',
"no-unused-vars":"off"
}
}

2
.gitignore vendored
View File

@ -24,3 +24,5 @@ makefile
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
components.d.ts
auto-imports.d.ts

143
README.md
View File

@ -1,87 +1,104 @@
# Lumen IM 即时聊天 # IM - 在线即时通讯应用
<img alt="GitHub stars badge" src="https://img.shields.io/github/stars/gzydong/LumenIM"> <img alt="GitHub forks badge" src="https://img.shields.io/github/forks/gzydong/LumenIM"> <img alt="GitHub license badge" src="https://img.shields.io/github/license/gzydong/LumenIM"> IM 是一个基于 Vue 3 开发的现代化在线即时通讯应用,提供实时聊天、消息管理、笔记等功能。
### 项目介绍 ## 功能特性
Lumen IM 是一个网页版在线聊天项目,前端使用 Naive UI + Vue3后端采用 GO 开发。 - 📱 实时聊天:支持一对一即时通讯
- 📝 消息管理:高效管理各类消息
- 📓 笔记功能支持Markdown格式的笔记编辑与管理
- 🌓 暗色模式:支持明暗主题切换,呵护您的眼睛
- 🔒 用户认证:完善的登录注册系统
### 功能模块 ## 技术栈
- 支持私聊及群聊 - **前端框架**Vue 3 + TypeScript
- 支持多种聊天消息类型 例如:文本消息、代码块、群投票、图片及其它类型文件,并支持文件下载 - **状态管理**Pinia
- 支持聊天消息撤回、删除(批量删除)、转发消息(逐条转发、合并转发) - **UI组件库**Naive UI
- 支持编写笔记 - **路由管理**Vue Router
- **CSS预处理器**Less
- **构建工具**Vite
- **WebSocket**:用于实时通讯
- **编辑器**
- Markdown编辑器@kangc/v-md-editor
- 富文本编辑器Quill
### 项目预览 ## 快速开始
- 地址: [http://im.gzydong.com](http://im.gzydong.com) ### 环境要求
### 项目安装 - Node.js >= 14.0.0
- pnpm >= 6.0.0
###### 下载安装 ### 安装依赖
```bash ```bash
## 克隆项目源码包 pnpm install
git clone https://gitee.com/gzydong/LumenIM.git
git clone https://github.com/gzydong/LumenIM.git
## 安装项目依赖扩展组件
yarn install
# 启动本地开发环境
yarn dev
# 启动本地开发环境桌面客户端
yarn electron:dev
## 生产环境构建项目
yarn build
## 生产环境桌面客户端打包
yarn electron:build
``` ```
###### 修改 .env 配置信息 ### 开发环境运行
```env ```bash
VITE_BASE_API=http://127.0.0.1:8503 # 测试环境
VITE_SOCKET_API=ws://127.0.0.1:8504 pnpm dev:test
# 生产环境
pnpm dev:prod
``` ```
###### 关于 Nginx 的一些配置 ### 打包构建
```nginx ```bash
server { # 测试环境构建
listen 80; pnpm build:test
server_name www.yourdomain.com;
root /project-path/dist; # 生产环境构建
index index.html; pnpm build:prod
location / {
try_files $uri $uri/ /index.html;
}
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf|flv|ico)$ {
expires 7d;
}
location ~ .*\.(js|css)?$ {
expires 7d;
}
}
``` ```
### 项目源码 ### 预览构建后的项目
| 代码仓库 | 前端源码 | 后端源码 | ```bash
| -------- | ---------------------------------- | ---------------------------------- | pnpm preview
| Github | https://github.com/gzydong/LumenIM | https://github.com/gzydong/go-chat | ```
| 码云 | https://gitee.com/gzydong/LumenIM | https://gitee.com/gzydong/go-chat |
#### 联系方式 ## 项目结构
QQ作者 : 837215079 ```
src/
├── api/ # API请求
├── assets/ # 静态资源
├── components/ # 公共组件
├── connect.ts # WebSocket连接管理
├── constant/ # 常量定义
├── directive/ # 自定义指令
├── event/ # 事件管理
├── hooks/ # 自定义钩子
├── layout/ # 布局组件
├── main.ts # 入口文件
├── plugins/ # 插件配置
├── router/ # 路由配置
├── store/ # 状态管理
├── types/ # 类型定义
├── utils/ # 工具函数
└── views/ # 页面视图
```
### 如果你觉得还不错,请 Star , Fork 给作者鼓励一下。 ## 环境变量配置
项目支持不同环境配置,环境变量文件位于`env/`目录下。
## 浏览器支持
支持现代浏览器如Chrome、Firefox、Safari、Edge等。
## 相关链接
- [Vue 3](https://v3.vuejs.org/)
- [Vite](https://vitejs.dev/)
- [Naive UI](https://www.naiveui.com/)
- [Pinia](https://pinia.vuejs.org/)
## 许可证
Copyright © 2023 IM

View File

@ -1,92 +0,0 @@
// 控制应用生命周期和创建原生浏览器窗口的模组
const { app, BrowserWindow, ipcMain, Menu, MenuItem } = require('electron')
const path = require('path')
const { shell } = require('electron')
const NODE_ENV = process.env.NODE_ENV
function loadHtmlUrl() {
return NODE_ENV === 'development'
? `http://localhost:${process.env.PROT}`
: `file://${path.join(__dirname, '../dist/index.html')}`
}
function createWindow() {
// 创建浏览器窗口
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
frame: false,
titleBarStyle: 'hidden',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
},
})
// 加载 index.html
win.loadURL(loadHtmlUrl())
// 打开开发工具
if (NODE_ENV === 'development') {
win.webContents.openDevTools()
}
// 进入全屏模式
win.on('enter-full-screen', function () {
win.webContents.send('full-screen', 'enter')
})
// 退出全屏模式
win.on('leave-full-screen', function () {
win.webContents.send('full-screen', 'leave')
})
ipcMain.on('get-full-screen', (e, data) => {
e.returnValue = win.isFullScreen()
})
ipcMain.on('app-info', (e, data) => {
e.returnValue = {
platform: process.platform,
version: app.getVersion(),
appPath: app.getAppPath(),
}
})
}
// 这段程序将会在 Electron 结束初始化
// 和创建浏览器窗口的时候调用
// 部分 API 在 ready 事件触发后才能使用。
app.whenReady().then(() => {
createWindow()
app.on('activate', function () {
// 通常在 macOS 上,当点击 dock 中的应用程序图标时,如果没有其他
// 打开的窗口,那么程序会重新创建一个窗口。
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 除了 macOS 外,当所有窗口都被关闭的时候退出程序。 因此,通常对程序和它们在
// 任务栏上的图标来说,应当保持活跃状态,直到用户使用 Cmd + Q 退出。
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
// 在这个文件中,你可以包含应用程序剩余的所有部分的代码,
// 也可以拆分成几个文件,然后用 require 导入。
ipcMain.on('ipc:set-badge', async (event, num) => {
if (process.platform === 'darwin') {
app.dock.setBadge(num > 99 ? '99+' : num)
}
})
ipcMain.on('ipc:open-link', async (event, link) => {
// Open a link in the default browser
shell.openExternal(link)
})

View File

@ -1,46 +0,0 @@
const { contextBridge, ipcRenderer } = require('electron')
// 暴露方法给渲染进程调用
contextBridge.exposeInMainWorld('electron', {
// 设置消息未读数
setBadge: num => {
ipcRenderer.send('ipc:set-badge', num == 0 ? '' : `${num}`)
},
// 获取窗口全屏状态
getFullScreenStatus: () => {
return ipcRenderer.sendSync('get-full-screen', '')
},
// 系统信息
getAppPlatform: () => {
return ipcRenderer.sendSync('app-info', '')
},
openLink: link => {
ipcRenderer.send('ipc:open-link', link)
},
})
// 窗口变化事件
ipcRenderer.on('full-screen', function (event, value) {
// isFullScreenStatus = value == 'enter'
document.dispatchEvent(
new CustomEvent('full-screen-event', { detail: value })
)
})
// 触发自定义事件
// document.dispatchEvent(new CustomEvent('myTestEvent', {num: i}))
// document.addEventListener('myTestEvent', e => {console.log(e)})
// 所有Node.js API都可以在预加载过程中使用。
// 它拥有与Chrome扩展一样的沙盒。
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
const element = document.getElementById(selector)
if (element) element.innerText = text
}
for (const dependency of ['chrome', 'node', 'electron']) {
replaceText(`${dependency}-version`, process.versions[dependency])
}
})

View File

12
env/.env.test vendored Normal file
View File

@ -0,0 +1,12 @@
ENV = 'development'
VITE_BASE=/
VUE_APP_PREVIEW=false
#VITE_BASE_API=http://192.168.88.21:9503
#VITE_SOCKET_API=ws://192.168.88.21:9504
VITE_BASE_API=http://114.218.158.24:8503
VITE_SOCKET_API=ws://114.218.158.24:8504
VITE_EPR_BASEURL=http://114.218.158.24:9020
VITE_PAGE_URL=http://172.16.100.93:9032
VUE_APP_WEBSITE_NAME=""

View File

@ -5,7 +5,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="./src/assets/image/favicon.png" /> <link rel="icon" href="./src/assets/image/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lumen IM 在线聊天</title> <title> 在线聊天</title>
<style> <style>
.outer, .outer,
.middle, .middle,

View File

@ -1,33 +1,43 @@
{ {
"name": "LumenIM", "name": "IM",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"main": "electron/main.js", "type": "module",
"scripts": { "scripts": {
"dev": "vite --mode development --port 5273", "dev:test": "vite --mode test --port 5273",
"build": "vite build", "dev:prod": "vite --mode prod --port 5273",
"build:test": "vite build --mode test",
"build:prod": "vite build --mode test",
"preview": "vite preview", "preview": "vite preview",
"electron": "wait-on tcp:5174 && cross-env NODE_ENV=development PROT=5174 electron .",
"electron:dev": "concurrently -k \"npm run dev\" \"npm run electron\"",
"electron:build": "vite build --mode electron && electron-builder --mac && electron-builder --win --x64",
"electron:build-mac": "vite build --mode electron && electron-builder --mac",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false", "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@highlightjs/vue-plugin": "^2.1.0", "@highlightjs/vue-plugin": "^2.1.0",
"@iconify-json/ion": "^1.2.3",
"@kangc/v-md-editor": "^2.3.18", "@kangc/v-md-editor": "^2.3.18",
"@onlyoffice/document-editor-vue": "^1.5.0",
"@vicons/fluent": "^0.13.0",
"@vicons/ionicons4": "^0.13.0",
"@vicons/ionicons5": "^0.13.0",
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^10.7.0", "@vueuse/core": "^10.7.0",
"ant-design-vue": "^4.2.6",
"axios": "^1.6.2", "axios": "^1.6.2",
"dayjs": "^1.11.13",
"highlight.js": "^11.5.0", "highlight.js": "^11.5.0",
"js-audio-recorder": "^1.0.7", "js-audio-recorder": "^1.0.7",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0", "pinia-plugin-persistedstate": "^3.2.0",
"pnpm": "^10.10.0",
"quill": "^1.3.7", "quill": "^1.3.7",
"quill-image-uploader": "^1.3.0", "quill-image-uploader": "^1.3.0",
"quill-mention": "^4.1.0", "quill-mention": "^4.1.0",
"sortablejs": "^1.15.6",
"viewerjs": "^1.11.7",
"vue": "^3.3.11", "vue": "^3.3.11",
"vue-cropper": "^1.1.1", "vue-cropper": "^1.1.1",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
@ -37,44 +47,41 @@
}, },
"devDependencies": { "devDependencies": {
"@icon-park/vue-next": "^1.4.2", "@icon-park/vue-next": "^1.4.2",
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node18": "^18.2.2", "@tsconfig/node18": "^18.2.2",
"@types/node": "^18.18.5", "@types/node": "^18.18.5",
"@types/vue": "^2.0.0", "@types/vue": "^2.0.0",
"@unocss/reset": "^66.1.1",
"@vitejs/plugin-vue": "^4.4.0", "@vitejs/plugin-vue": "^4.4.0",
"@vitejs/plugin-vue-jsx": "^3.0.2", "@vitejs/plugin-vue-jsx": "^3.0.2",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.4.0",
"concurrently": "^7.3.0", "concurrently": "^7.3.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"electron": "^19.1.9",
"electron-builder": "^23.6.0",
"eslint": "^8.49.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-vue": "^9.17.0",
"less": "^4.2.0", "less": "^4.2.0",
"less-loader": "^11.1.3", "less-loader": "^11.1.3",
"naive-ui": "^2.35.0", "naive-ui": "^2.35.0",
"npm-run-all2": "^6.1.1", "npm-run-all2": "^6.1.1",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"sass": "^1.88.0",
"typescript": "~5.2.0", "typescript": "~5.2.0",
"vite": "^4.5.1", "unocss": "0.58.0",
"unplugin-auto-import": "^19.2.0",
"unplugin-vue-components": "^28.5.0",
"vite": "^6.3.5",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.7.6",
"vue-tsc": "^1.8.25", "vue-tsc": "^1.8.25",
"wait-on": "^6.0.1" "wait-on": "^6.0.1"
}, },
"build": { "build": {
"appId": "com.gzydong.lumenim", "appId": "com.gzydong.im",
"productName": "LumenIM", "productName": "IM",
"copyright": "Copyright © 2023 LumenIM", "copyright": "Copyright © 2023 IM",
"mac": { "mac": {
"category": "public.app-category.utilities", "category": "public.app-category.utilities",
"icon": "build/icons/lumen-im-mac.png" "icon": "build/icons/-im-mac.png"
}, },
"win": { "win": {
"icon": "build/icons/lumen-im-mac.png", "icon": "build/icons/-im-mac.png",
"target": [ "target": [
{ {
"target": "nsis" "target": "nsis"
@ -84,20 +91,12 @@
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"installerIcon": "build/icons/lumen-im-win.ico", "installerIcon": "build/icons/-im-win.ico",
"uninstallerIcon": "build/icons/lumen-im-win.ico", "uninstallerIcon": "build/icons/-im-win.ico",
"installerHeaderIcon": "build/icons/lumen-im-win.ico", "installerHeaderIcon": "build/icons/-im-win.ico",
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,
"shortcutName": "lumeim-icon" "shortcutName": "lumeim-icon"
},
"files": [
"dist/**/*",
"electron/**/*"
],
"directories": {
"buildResources": "assets",
"output": "dist_electron"
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -36,7 +36,7 @@ IconProvider({
strokeLinejoin: 'bevel' strokeLinejoin: 'bevel'
}) })
const { uid: showUserId, isShow: isShowUser } = useProvideUserModal() const { uid: showUserId, isShow: isShowUser,euid } = useProvideUserModal()
const { getDarkTheme, getThemeOverride } = useThemeMode() const { getDarkTheme, getThemeOverride } = useThemeMode()
const userStore = useUserStore() const userStore = useUserStore()
@ -94,6 +94,7 @@ useClickEvent()
<UserCardModal <UserCardModal
v-model:show="isShowUser" v-model:show="isShowUser"
v-model:uid="showUserId" v-model:uid="showUserId"
:euid="euid"
@update-remark="onChangeRemark" @update-remark="onChangeRemark"
/> />
</n-layout-content> </n-layout-content>

View File

@ -25,3 +25,7 @@ export const ServeRefreshToken = () => {
export const ServeForgetPassword = (data) => { export const ServeForgetPassword = (data) => {
return post('/api/v1/auth/forget', data) return post('/api/v1/auth/forget', data)
} }
// 获取用户信息服务
export const GetUserInfo = (data) => {
return post('/api/v1/users/info', data)
}

View File

@ -9,7 +9,10 @@ export const ServeGetTalkList = (data = {}) => {
export const ServeCreateTalkList = (data = {}) => { export const ServeCreateTalkList = (data = {}) => {
return post('/api/v1/talk/create', data) return post('/api/v1/talk/create', data)
} }
// 聊天列表创建服务接口
export const voiceToText = (data = {}) => {
return post('/api/v1/talk/message/voice-to-text', data)
}
// 删除聊天列表服务接口 // 删除聊天列表服务接口
export const ServeDeleteTalkList = (data = {}) => { export const ServeDeleteTalkList = (data = {}) => {
return post('/api/v1/talk/delete', data) return post('/api/v1/talk/delete', data)
@ -32,7 +35,7 @@ export const ServeTalkRecords = (data = {}) => {
// 获取转发会话记录详情列表服务接口 // 获取转发会话记录详情列表服务接口
export const ServeGetForwardRecords = (data = {}) => { export const ServeGetForwardRecords = (data = {}) => {
return get('/api/v1/talk/records/forward', data) return get('/api/v1/talk/records/forward/v2', data)
} }
// 对话列表置顶服务接口 // 对话列表置顶服务接口
@ -86,3 +89,23 @@ export const ServeSendVote = (data = {}) => {
export const ServeConfirmVoteHandle = (data = {}) => { export const ServeConfirmVoteHandle = (data = {}) => {
return post('/api/v1/talk/message/vote/handle', data) return post('/api/v1/talk/message/vote/handle', data)
} }
//清空聊天记录
export const ServeEmptyMessage = (data) => {
return post('/api/v1/talk/message/empty', data)
}
//获取消息已读未读详情
export const ServeMessageReadDetail = (data) => {
return post('/api/v1/talk/my-records/read/condition', data)
}
// 主动添加好友(单向好友)
export const ServeAddFriend = (data) => {
return post('/api/v1/contact/friend/add', data)
}
// 检测是否需要加好友
export const ServeCheckFriend = (data) => {
return post('/api/v1/contact/friend/check', data)
}

22
src/api/components.js Normal file
View File

@ -0,0 +1,22 @@
import _axios from '@/utils/erpRequest'
export default {
deleteDataByParams: (url, data) => _axios.fetch(url, data, 'DELETE'),
putDataByParams: (url, data) => _axios.fetch(url, data, 'PUT'),
postDataByParams: (url, data) => _axios.fetch(url, data, 'POST'),
findDates: ( data) => _axios.fetch('/report/find/dates', data, 'GET'),
postBlobByParams: (url, data) => _axios.fetch(url, data, 'POST', 'blob'),
getDataByParams: (url, data) => _axios.fetch(url, data, 'GET'),
getBlobByParams: (url, data) => _axios.fetch(url, data, 'GET', 'blob'),
uploadFormData: (url, data) => _axios.fetch(url, data, 'POST', 'json', '', true, true),
viewDetails: (data) => _axios.fetch('/health/info', data, 'POST'),
healthDelex: (data) => _axios.fetch('/health/delex', data, 'POST'),
healthDrde: (data) => _axios.fetch('/health/drde', data, 'POST'),
healthEdit: (data) => _axios.fetch('/health/edit', data, 'POST'),
healthAdddr: (data) => _axios.fetch('/health/adddr', data, 'POST'),
healthEditStreet: (data) => _axios.fetch('/health/editstreet', data, 'POST'),
healthIllmessage: (data) => _axios.fetch('/health/illmessage', data, 'POST'),
healthCall: (url, data) => _axios.fetch(url, data, 'POST'),
promotionDownload: (data) => _axios.fetch('/collections/extend', data, 'POST', 'blob'),
//只能看到我所在的组织机构树
viewMyTree: (data) => _axios.fetch('/department/v2/tree/my', data, 'POST'),
}

View File

@ -45,10 +45,12 @@ export const ServeFindFriendApplyNum = () => {
} }
// 搜索用户信息服务接口 // 搜索用户信息服务接口
// export const ServeSearchUser = (data) => {
// return get('/api/v1/contact/detail', data)
// }
export const ServeSearchUser = (data) => { export const ServeSearchUser = (data) => {
return get('/api/v1/contact/detail', data) return post('/api/v1/users/info', data)
} }
// 搜索用户信息服务接口 // 搜索用户信息服务接口
export const ServeContactGroupList = (data) => { export const ServeContactGroupList = (data) => {
return get('/api/v1/contact/group/list', data) return get('/api/v1/contact/group/list', data)

View File

@ -77,6 +77,11 @@ export const ServeEditGroupNotice = (data) => {
return post('/api/v1/group/notice/edit', data) return post('/api/v1/group/notice/edit', data)
} }
// 删除群公告
export const ServeDeleteGroupNotice = (data) => {
return post('/api/v1/group/notice/delete', data)
}
export const ServeGetGroupApplyList = (data) => { export const ServeGetGroupApplyList = (data) => {
return get('/api/v1/group/apply/list', data) return get('/api/v1/group/apply/list', data)
} }

18
src/api/index.js Normal file
View File

@ -0,0 +1,18 @@
// 使用 `import.meta.glob` 来同步导入所有匹配的模块
// 使用 `{ eager: true }` 选项来立即加载这些模块
const modules = import.meta.glob('./*.js', { eager: true });
const HTTP = {};
for (const path in modules) {
if (Object.hasOwnProperty.call(modules, path)) {
// 正确移除 './' 和 '.js',只保留文件名
const componentName = path.replace(/^\.\/(.*)\.\w+$/, '$1');
if (componentName !== 'index') {
// 确保我们只获取模块的默认导出
HTTP[componentName] = modules[path]?.default;
}
}
}
// 导出 HTTP 对象
export default { HTTP };

36
src/api/search.js Normal file
View File

@ -0,0 +1,36 @@
import { post, get, upload } from '@/utils/request'
//ES搜索-主页搜索什么都有、指定用户、指定群、群与用户概览
export const ServeSeachQueryAll = (data = {}) => {
return post('/api/v1/elasticsearch/query-all/v2', data)
}
// ES搜索用户数据
export const ServeQueryUser = (data) => {
return post('/api/v1/elasticsearch/query-user/v2', data)
}
// ES搜索群组数据
export const ServeQueryGroup = (data) => {
return post('/api/v1/elasticsearch/query-group', data)
}
//ES搜索聊天记录-主页搜索什么都有、聊天记录
export const ServeQueryTalkRecord = (data = {}) => {
return post('/api/v1/elasticsearch/query-talk-record', data)
}
//查看存在聊天记录的天数
export const ServeTalkDate = (data) => {
return post('/api/v1/talk/date', data)
}
//获取会话Id
export const ServeGetSessionId = (data) => {
return post('/api/v1/talk/session/getId', data)
}
//获取用户所在群聊列表
export const ServeUserGroupChatList = (data) => {
return post('/api/v1/group/user/list', data)
}

View File

@ -21,6 +21,9 @@ export const ServeFileSubareaUpload = (data = {}, options = {}) => {
} }
// 上传图片文件或者视频 // 上传图片文件或者视频
export const uploadImg = (data) => { export const uploadImg = (data, signal) => {
return post('/upload/img', data,{baseURL:import.meta.env.VITE_EPR_BASEURL}) return post('/upload/img', data, {
baseURL: import.meta.env.VITE_EPR_BASEURL,
signal: signal
})
} }

View File

@ -29,3 +29,8 @@ export const ServeGetUserDetail = () => {
export const ServeGetUserSetting = () => { export const ServeGetUserSetting = () => {
return get('/api/v1/users/setting') return get('/api/v1/users/setting')
} }
//根据erpUserId查询聊天系统用户详情
export const getUserInfoByERPUserId = (data) => {
return post('/api/v1/users/info', data)
}

View File

@ -1,6 +1,7 @@
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box!important;
} }
@font-face { @font-face {
@ -15,6 +16,7 @@
body, body,
html { html {
margin-right: 0!important;
height: 100%; height: 100%;
min-width: 500px; min-width: 500px;
color: #333; color: #333;
@ -204,7 +206,7 @@ textarea {
border-radius: 2px; border-radius: 2px;
cursor: default; cursor: default;
user-select: none; user-select: none;
background-color: #dee0e3;
transform: scale(0.84); transform: scale(0.84);
transform-origin: left; transform-origin: left;
flex-shrink: 0; flex-shrink: 0;

View File

@ -1,10 +1,10 @@
// 默认主题 // 默认主题
html { html {
--im-primary-color: #1890ff; --im-primary-color: #462AA0;
--im-bg-color: #ffffff; --im-bg-color: #ffffff;
--line-border-color: #f5f5f5; --line-border-color: #f5f5f5;
--border-color: #eeeaea; --border-color: #eeeaea;
--im-text-color: #333; --im-text-color: #BABABA;
--im-text-color-grey: #333; --im-text-color-grey: #333;
--im-active-bg-color: #f5f5f5; --im-active-bg-color: #f5f5f5;
--im-hover-bg-color: #f5f5f5; --im-hover-bg-color: #f5f5f5;
@ -21,15 +21,15 @@ html {
// message // message
--im-message-bg-color: #f7f7f7; --im-message-bg-color: #f7f7f7;
--im-message-border-color: #efeff5; --im-message-border-color: #efeff5;
--im-message-left-bg-color: #eff0f1; --im-message-left-bg-color: #fff;
--im-message-left-text-color: #333; --im-message-left-text-color: #333;
--im-message-right-bg-color: #daf3fd; --im-message-right-bg-color: #46299D;
--im-message-right-text-color: #333; --im-message-right-text-color: #fff;
} }
// 黑色主题 // 黑色主题
html[theme-mode='dark'] { html[theme-mode='dark'] {
--im-primary-color: #1890ff; --im-primary-color: #462AA0;
--im-bg-color: #202124; --im-bg-color: #202124;
--line-border-color: rgb(255 255 255 / 9%); --line-border-color: rgb(255 255 255 / 9%);
--border-color: rgb(255 255 255 / 9%); --border-color: rgb(255 255 255 / 9%);

View File

@ -12,7 +12,7 @@
&:hover, &:hover,
&.dropsize-resizing { &.dropsize-resizing {
background-color: #1890ff; background-color: #462AA0;
} }
&.dropsize-line-top { &.dropsize-line-top {

View File

@ -0,0 +1,68 @@
/* naive ui 部分样式调整*/
/*表格排序图标颜色问题 */
.n-data-table-sorter{
color: #fff!important;
}
.n-checkbox-box-wrapper .n-checkbox-box{
border-radius: 50%;
}
/*表格头多选框颜色调整避免和表头颜色冲突*/
.n-data-table-thead .n-data-table-tr .n-checkbox-box{
background: #fff;
.n-checkbox-icon{
.check-icon{
fill:#462AA0 ;
}
svg{
fill:#462AA0 ;
}
}
.n-checkbox-box__border{
border: #fff!important;
}
}
/*弹窗内表格背景颜色调整*/
.n-data-table .n-data-table-th {
background-color: #462AA0;
}
/*
naive ui 消息提示框 样式调整
*/
.n-message-wrapper{
.n-message{
&.n-message--info-type{
border: 1px solid #C7DFFB;
background-color: #EDF5FE;
}
&.n-message--warning-type{
border: 1px solid #FAE0B5;
background-color: #FEF7ED;
}
&.n-message--error-type{
border: 1px solid #F3CBD3;
background-color:#FBEEF1;
}
&.n-message--success-type{
border: 1px solid #C5E7D5;
background-color:#EDF7F2;
}
&.n-message--loading-type{
border: 1px solid #B2A6D6;
background-color:#EDF7F2;
}
}
}
/*
n-image 图片放大查看器工具栏样式调整 样式污染问题
*/
.n-base-icon{
box-sizing: initial!important;
}
/*表格排序列背景颜色问题*/
.n-data-table .n-data-table-th.n-data-table-th--sortable{
background-color: #462AA0;
}
.n-data-table .n-data-table-th.n-data-table-th--sortable:hover{
background-color: #462AA0;
}

BIN
src/assets/image/bofang.png Normal file

Binary file not shown.

After

(image error) Size: 2.0 KiB

Binary file not shown.

After

(image error) Size: 530 B

Binary file not shown.

After

(image error) Size: 657 B

Binary file not shown.

After

(image error) Size: 6.7 KiB

Binary file not shown.

After

(image error) Size: 337 B

Binary file not shown.

After

(image error) Size: 486 B

Binary file not shown.

After

(image error) Size: 473 B

BIN
src/assets/image/close.png Normal file

Binary file not shown.

After

(image error) Size: 1.2 KiB

BIN
src/assets/image/dofd.png Normal file

Binary file not shown.

After

(image error) Size: 396 B

Binary file not shown.

After

(image error) Size: 2.3 KiB

Binary file not shown.

After

(image error) Size: 1.2 KiB

Binary file not shown.

After

(image error) Size: 2.2 KiB

Binary file not shown.

After

(image error) Size: 607 B

Binary file not shown.

After

(image error) Size: 5.5 KiB

Binary file not shown.

After

(image error) Size: 5.6 KiB

Binary file not shown.

After

(image error) Size: 6.3 KiB

Binary file not shown.

After

(image error) Size: 5.1 KiB

Binary file not shown.

After

(image error) Size: 163 B

Binary file not shown.

After

(image error) Size: 286 B

Binary file not shown.

After

(image error) Size: 1.2 KiB

Binary file not shown.

After

(image error) Size: 436 B

Binary file not shown.

Binary file not shown.

After

(image error) Size: 1.8 KiB

Binary file not shown.

After

(image error) Size: 1.5 KiB

Binary file not shown.

After

(image error) Size: 2.9 KiB

Binary file not shown.

After

(image error) Size: 684 B

BIN
src/assets/image/yuyin.png Normal file

Binary file not shown.

After

(image error) Size: 3.3 KiB

Binary file not shown.

After

(image error) Size: 26 KiB

Binary file not shown.

After

(image error) Size: 118 KiB

Binary file not shown.

After

(image error) Size: 4.3 KiB

Binary file not shown.

After

(image error) Size: 4.5 KiB

Binary file not shown.

After

(image error) Size: 4.5 KiB

Binary file not shown.

After

(image error) Size: 1.3 KiB

View File

@ -0,0 +1,144 @@
<template>
<div class="relative">
<div class="avatar-module" :style="[customStyle, { background: avatar ? '#fff' : '' }]">
<img :src="avatar" v-if="avatar" />
<span v-else :style="customTextStyle">{{ text_avatar }}</span>
</div>
<div
v-if="[2,3,4].includes(groupType)&&showGroupType"
class="absolute border-2px border-solid rounded-3px bg-#fff flex justify-center items-center leading-none"
:style="[
groupLabelStyle,
`color:${labelColor.find(x=>x.group_type===groupType)?.color};border-color:${labelColor.find(x=>x.group_type===groupType)?.color}`
]"
>
{{ labelColor.find(x=>x.group_type===groupType)?.label }}
</div>
</div>
</template>
<script setup>
//
import groupNormal from '@/assets/image/groupNormal.png'
import groupDepartment from '@/assets/image/groupDepartment.png'
import groupProject from '@/assets/image/groupProject.png'
import groupCompany from '@/assets/image/groupCompany.png'
import { computed, defineProps } from 'vue'
//1=2=3=4=/
const labelColor=[
{group_type:2,color:'#377EC6',label:'部门'},
{group_type:3,color:'#C1691C',label:'项目'},
{group_type:4,color:'#7A58DE',label:'公司'},
]
const props = defineProps({
mode: {
//1=2=
type: Number,
default: 0,
},
showGroupType:{
type:Boolean,
default:false
},
avatar: {
//
type: String,
default: '',
},
userName: {
//
type: String,
default: '',
},
groupType: {
//1=2=3=4=/
type: Number,
default: 0,
},
customStyle: {
//
type: Object,
default() {
return {}
},
},
customTextStyle: {
//
type: Object,
default() {
return {}
},
},
})
//
const avatar = computed(() => {
let avatar_img = props?.avatar
if (!avatar_img) {
if (props?.mode === 1) {
} else if (props?.mode === 2) {
if (props?.groupType === 1) {
avatar_img = groupNormal
} else if (props?.groupType === 2) {
avatar_img = groupDepartment
} else if (props?.groupType === 3) {
avatar_img = groupProject
} else if (props?.groupType === 4) {
avatar_img = groupCompany
}
}
}
return avatar_img
})
//
const text_avatar = computed(() => {
return props?.userName.length >= 2
? props?.userName.slice(-2)
: props?.userName
})
//
const groupLabelStyle = computed(() => {
//
const avatarWidth = parseInt(props.customStyle.width) || 42
const avatarHeight = parseInt(props.customStyle.height) || 42
// 42px32px18px10px
const widthRatio = avatarWidth / 42
const heightRatio = avatarHeight / 42
//
const labelWidth = Math.round(32 * widthRatio)
const labelHeight = Math.round(18 * heightRatio)
const fontSize = Math.round(10 * widthRatio)
// top-28px
const topPosition = Math.round(28 * heightRatio)
return {
width: `${labelWidth}px`,
height: `${labelHeight}px`,
fontSize: `${fontSize}px`,
top: `${topPosition}px`,
left: '50%',
transform: 'translateX(-50%)'
}
})
</script>
<style lang="less" scoped>
.avatar-module {
border-radius: 50%;
overflow: hidden;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background: linear-gradient(to right, #674bbc, #46299d);
flex-shrink: 0;
img {
width: 42px;
height: 42px;
object-fit: cover;
}
}
</style>

View File

@ -44,7 +44,7 @@
font-feature-settings: 'tnum'; font-feature-settings: 'tnum';
position: absolute; position: absolute;
display: none; display: none;
color: #1890ff; color: #462AA0;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
opacity: 0; opacity: 0;
@ -177,7 +177,7 @@
display: block; display: block;
width: 9px; width: 9px;
height: 9px; height: 9px;
background-color: #1890ff; background-color: #462AA0;
border-radius: 100%; border-radius: 100%;
-webkit-transform: scale(0.75); -webkit-transform: scale(0.75);
transform: scale(0.75); transform: scale(0.75);

View File

@ -0,0 +1,20 @@
<template>
<n-button
v-bind="$attrs"
>
<template
v-for="(slot, name) in $slots"
:key="name"
#[name]
>
<slot :name="name"></slot>
</template>
</n-button>
</template>
<script setup>
import { NButton } from 'naive-ui'
</script>
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,156 @@
<template>
<xNModal v-model:show="show" v-bind="$attrs">
<template #header>
<div class="custom-modal-header">
<div class="header-content">
<template v-if="$slots.header">
<slot name="header"></slot>
</template>
<template v-else>
{{ title }}
</template>
<div class="custom-close-btn" v-if="customCloseBtn">
<img src="@/assets/image/icon/close-btn-grey.png" alt="" @click="handleCloseModal" />
</div>
</div>
</div>
</template>
<slot name="content"></slot>
<template #footer v-if="actionBtns?.cancelBtn || actionBtns?.confirmBtn">
<div
class="custom-modal-btns"
:style="props?.customModalBtnsStyle ? props.customModalBtnsStyle : ''"
>
<customBtn
color="#C7C7C9"
style="width: 161px; height: 34px;"
@click="handleCancel"
v-if="actionBtns?.cancelBtn"
>{{ actionBtns?.cancelBtn?.text || '取消' }}</customBtn
>
<customBtn
color="#46299D"
style="width: 161px; height: 34px;"
@click="handleConfirm"
:disabled="actionBtns?.confirmBtn?.disabled"
:loading="state.confirmBtnLoading && actionBtns?.confirmBtn?.doLoading"
v-if="actionBtns?.confirmBtn"
>{{ actionBtns?.confirmBtn?.text || '确定' }}</customBtn
>
</div>
</template>
</xNModal>
</template>
<script setup>
import { reactive, computed } from 'vue'
import xNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
import customBtn from '@/components/common/customBtn.vue'
const props = defineProps({
show: {
//
type: Boolean,
default: false
},
title: {
//
type: String,
default: ''
},
actionBtns: {
//
type: Object,
default: () => ({})
},
customCloseBtn: {
//
type: Boolean,
default: false
},
customModalBtnsStyle: {
//
type: String,
default: ''
},
customCloseEvent: {
//
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:show', 'cancel', 'confirm', 'customCloseModal'])
const show = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const handleCancel = () => {
if (props.actionBtns?.cancelBtn?.hideModal) {
show.value = false
}
emit('cancel')
}
const handleConfirm = () => {
if (props.actionBtns?.confirmBtn?.doLoading) {
state.confirmBtnLoading = true
}
emit('confirm', closeLoading)
}
const closeLoading = () => {
state.confirmBtnLoading = false
}
const state = reactive({
confirmBtnLoading: false // loading
})
const handleCloseModal = () => {
if (props.customCloseEvent) {
emit('customCloseModal')
} else {
show.value = false
}
}
</script>
<style scoped lang="less">
.custom-modal-header {
border-bottom: 1px solid #e5e5e5;
margin: 0 12px;
.header-content {
padding: 0 0 15px;
text-align: center;
color: #1f2225;
font-size: 20px;
font-weight: 600;
line-height: 28px;
position: relative;
.custom-close-btn {
position: absolute;
right: 0;
top: 0;
cursor: pointer;
img {
width: 30px;
height: 30px;
}
}
}
}
.custom-modal-btns {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding: 0 0 50px;
}
</style>

View File

@ -0,0 +1,49 @@
<script setup>
import { ref, watch } from 'vue'
import XNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
const emit = defineEmits(['cancel','confirm'])
const show=defineModel('show')
const props = defineProps({
title:{
type:String,
default:'提示'
},
content:{
type:String,
default:'内容'
},
cancelText:{
type:String,
default:'取消'
},
confirmText:{
type:String,
default:'确定'
}
})
</script>
<template>
<XNModal v-model:show="show" :closable="false" class="w-724px" content-style="padding:0px" @after-leave="emit('after-leave')">
<div class="flex flex-col w-full px-25px pb-49px">
<div class="text-20px text-#1F2225 w-full text-center border-b-1px border-b-solid border-b-#E9E9E9 py-20px">{{ title }}</div>
<div class="py-60px text-center text-20px text-#1F2225">
{{ content }}
</div>
<div class="flex w-full justify-center">
<n-button color="#C7C7C9" class="text-14px text-#fff w-161px h-34px mr-10px"
@click="() => { show=false; emit('cancel') }"
>{{ cancelText }}</n-button>
<n-button color="#46299D" class="text-14px text-#fff w-161px h-34px"
@click="() => { show=false; emit('confirm') }"
>{{ confirmText }}</n-button>
</div>
</div>
</XNModal>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,32 @@
import { createVNode, nextTick, render } from 'vue'
import ConfirmBox from './index.vue'
export function confirmBox(options) {
return new Promise((resolve, reject) => {
const container = document.createElement('div')
document.body.appendChild(container)
const props = {
...options,
show: false,
onCancel: () => {
reject()
},
onAfterLeave:()=>{
render(null, container)
document.body.removeChild(container)
},
onConfirm: () => {
resolve()
},
}
const vnode = createVNode(ConfirmBox, props)
render(vnode, container)
nextTick(() => {
vnode.component.props.show = true
})
})
}

File diff suppressed because it is too large Load Diff

View File

@ -1,81 +1,116 @@
<script lang="ts" setup> <script lang="ts" setup>
// Quill
import '@vueup/vue-quill/dist/vue-quill.snow.css' import '@vueup/vue-quill/dist/vue-quill.snow.css'
//
import 'quill-image-uploader/dist/quill.imageUploader.min.css' import 'quill-image-uploader/dist/quill.imageUploader.min.css'
//
import '@/assets/css/editor-mention.less' import '@/assets/css/editor-mention.less'
// Vue
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted } from 'vue' import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted } from 'vue'
// Naive UI
import { NPopover } from 'naive-ui' import { NPopover } from 'naive-ui'
//
import { import {
Voice as IconVoice, Voice as IconVoice, //
SourceCode, SourceCode, //
Local, Local, //
SmilingFace, SmilingFace, //
Pic, Pic, //
FolderUpload, FolderUpload, //
Ranking, Ranking, //
History History //
} from '@icon-park/vue-next' } from '@icon-park/vue-next'
// Quill
import { QuillEditor, Quill } from '@vueup/vue-quill' import { QuillEditor, Quill } from '@vueup/vue-quill'
//
import ImageUploader from 'quill-image-uploader' import ImageUploader from 'quill-image-uploader'
//
import EmojiBlot from './formats/emoji' import EmojiBlot from './formats/emoji'
//
import QuoteBlot from './formats/quote' import QuoteBlot from './formats/quote'
//
import 'quill-mention' import 'quill-mention'
//
import { useDialogueStore, useEditorDraftStore } from '@/store' import { useDialogueStore, useEditorDraftStore } from '@/store'
//
import { deltaToMessage, deltaToString, isEmptyDelta } from './util' import { deltaToMessage, deltaToString, isEmptyDelta } from './util'
//
import { getImageInfo } from '@/utils/functions' import { getImageInfo } from '@/utils/functions'
//
import { EditorConst } from '@/constant/event-bus' import { EditorConst } from '@/constant/event-bus'
//
import { emitCall } from '@/utils/common' import { emitCall } from '@/utils/common'
//
import { defAvatar } from '@/constant/default' import { defAvatar } from '@/constant/default'
import MeEditorVote from './MeEditorVote.vue' //
import MeEditorEmoticon from './MeEditorEmoticon.vue' import MeEditorVote from './MeEditorVote.vue' //
import MeEditorCode from './MeEditorCode.vue' import MeEditorEmoticon from './MeEditorEmoticon.vue' //
import MeEditorRecorder from './MeEditorRecorder.vue' import MeEditorCode from './MeEditorCode.vue' //
import MeEditorRecorder from './MeEditorRecorder.vue' //
// API
import { ServeUploadImage } from '@/api/upload' import { ServeUploadImage } from '@/api/upload'
import { uploadImg } from '@/api/upload' import { uploadImg } from '@/api/upload'
// 线
import { useEventBus } from '@/hooks' import { useEventBus } from '@/hooks'
// Quill
Quill.register('formats/emoji', EmojiBlot) //
Quill.register('formats/quote', QuoteBlot) //
Quill.register('modules/imageUploader', ImageUploader) //
Quill.register('formats/emoji', EmojiBlot) //
Quill.register('formats/quote', QuoteBlot)
Quill.register('modules/imageUploader', ImageUploader)
const emit = defineEmits(['editor-event']) const emit = defineEmits(['editor-event'])
//
const dialogueStore = useDialogueStore() const dialogueStore = useDialogueStore()
// 稿
const editorDraftStore = useEditorDraftStore() const editorDraftStore = useEditorDraftStore()
// props
const props = defineProps({ const props = defineProps({
vote: { vote: {
type: Boolean, type: Boolean,
default: false default: false //
}, },
members: { members: {
default: () => [] default: () => [] // @
} }
}) })
//
const editor = ref() const editor = ref()
// Quill
const getQuill = () => { const getQuill = () => {
return editor.value?.getQuill() return editor.value?.getQuill()
} }
//
const getQuillSelectionIndex = () => { const getQuillSelectionIndex = () => {
let quill = getQuill() let quill = getQuill()
return (quill.getSelection() || {}).index || quill.getLength() return (quill.getSelection() || {}).index || quill.getLength()
} }
//
const indexName = computed(() => dialogueStore.index_name) const indexName = computed(() => dialogueStore.index_name)
//
const isShowEditorVote = ref(false) const isShowEditorVote = ref(false)
//
const isShowEditorCode = ref(false) const isShowEditorCode = ref(false)
//
const isShowEditorRecorder = ref(false) const isShowEditorRecorder = ref(false)
// DOM
const fileImageRef = ref() const fileImageRef = ref()
// DOM
const uploadFileRef = ref() const uploadFileRef = ref()
//
const emoticonRef = ref() const emoticonRef = ref()
//
const editorOption = { const editorOption = {
debug: false, debug: false,
modules: { modules: {
toolbar: false, toolbar: false, //
clipboard: { clipboard: {
// //
matchers: [[Node.ELEMENT_NODE, onClipboardMatcher]] matchers: [[Node.ELEMENT_NODE, onClipboardMatcher]]
}, },
@ -83,19 +118,22 @@ const editorOption = {
bindings: { bindings: {
enter: { enter: {
key: 13, key: 13,
handler: onSendMessage handler: onSendMessage // Enter
} }
} }
}, },
//
imageUploader: { imageUploader: {
upload: onEditorUpload upload: onEditorUpload
}, },
// @
mention: { mention: {
allowedChars: /^[\u4e00-\u9fa5]*$/, allowedChars: /^[\u4e00-\u9fa5]*$/, //
mentionDenotationChars: ['@'], mentionDenotationChars: ['@'], // @
positioningStrategy: 'fixed', positioningStrategy: 'fixed', //
// @
renderItem: (data: any) => { renderItem: (data: any) => {
const el = document.createElement('div') const el = document.createElement('div')
el.className = 'ed-member-item' el.className = 'ed-member-item'
@ -103,16 +141,18 @@ const editorOption = {
el.innerHTML += `<span class="nickname">${data.nickname}</span>` el.innerHTML += `<span class="nickname">${data.nickname}</span>`
return el return el
}, },
//
source: function (searchTerm: string, renderList: any) { source: function (searchTerm: string, renderList: any) {
console.log("source")
if (!props.members.length) { if (!props.members.length) {
return renderList([]) return renderList([])
} }
let list = [ let list = [
{ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' },
...props.members ...props.members
] ] as any
if((dialogueStore.groupInfo as any).is_manager){
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
}
const items = list.filter( const items = list.filter(
(item: any) => item.nickname.toLowerCase().indexOf(searchTerm) !== -1 (item: any) => item.nickname.toLowerCase().indexOf(searchTerm) !== -1
) )
@ -123,66 +163,73 @@ const editorOption = {
} }
}, },
placeholder: '按Enter发送 / Shift+Enter 换行', placeholder: '按Enter发送 / Shift+Enter 换行',
theme: 'snow' theme: 'snow' // 使snow
} }
//
const navs = reactive([ const navs = reactive([
{ {
title: '图片', title: '图片',
icon: markRaw(Pic), icon: markRaw(Pic),
show: true, show: true,
click: () => { click: () => {
fileImageRef.value.click() fileImageRef.value.click() //
} }
}, },
{ {
title: '件', title: '件',
icon: markRaw(FolderUpload), icon: markRaw(FolderUpload),
show: true, show: true,
click: () => { click: () => {
uploadFileRef.value.click() uploadFileRef.value.click() //
} }
}, },
{ //
title: '代码', // {
icon: markRaw(SourceCode), // title: '',
show: true, // icon: markRaw(SourceCode),
click: () => { // show: true,
isShowEditorCode.value = true // click: () => {
} // isShowEditorCode.value = true
}, // }
{ // },
title: '语音消息', // {
icon: markRaw(IconVoice), // title: '',
show: true, // icon: markRaw(IconVoice),
click: () => { // show: true,
isShowEditorRecorder.value = true // click: () => {
} // isShowEditorRecorder.value = true
}, // }
{ // },
title: '地理位置', // {
icon: markRaw(Local), // title: '',
show: true, // icon: markRaw(Local),
click: () => {} // show: true,
}, // click: () => {}
{ // },
title: '群投票', // {
icon: markRaw(Ranking), // title: '',
show: computed(() => props.vote), // icon: markRaw(Ranking),
click: () => { // show: computed(() => props.vote),
isShowEditorVote.value = true // click: () => {
} // isShowEditorVote.value = true
}, // }
{ // },
title: '历史记录', // {
icon: markRaw(History), // title: '',
show: true, // icon: markRaw(History),
click: () => { // show: true,
emit('editor-event', emitCall('history_event')) // click: () => {
} // emit('editor-event', emitCall('history_event'))
} // }
// }
]) ])
/**
* 上传图片函数
* @param file 文件对象
* @returns Promise成功时返回图片URL
*/
function onUploadImage(file: File) { function onUploadImage(file: File) {
return new Promise((resolve) => { return new Promise((resolve) => {
let image = new Image() let image = new Image()
@ -190,35 +237,44 @@ function onUploadImage(file: File) {
image.onload = () => { image.onload = () => {
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
form.append("source", "fonchain-chat"); form.append("source", "fonchain-chat"); //
// form.append('width', image.width.toString()) // URL
// form.append('height', image.height.toString())
form.append("urlParam", `width=${image.width}&height=${image.height}`); form.append("urlParam", `width=${image.width}&height=${image.height}`);
// API
uploadImg(form).then(({ code, data, message }) => { uploadImg(form).then(({ code, data, message }) => {
if (code == 0) { if (code == 0) {
resolve(data.ori_url) resolve(data.ori_url) // URL
} else { } else {
resolve('') resolve('')
window['$message'].error(message) window['$message'].error(message) //
} }
}) })
} }
}) })
} }
/**
* 编辑器上传处理函数
* @param file 要上传的文件
* @returns Promise
*/
function onEditorUpload(file: File) { function onEditorUpload(file: File) {
async function fn(file: File, resolve: Function, reject: Function) { async function fn(file: File, resolve: Function, reject: Function) {
if (file.type.indexOf('image/') === 0) { if (file.type.indexOf('image/') === 0) {
// 使
return resolve(await onUploadImage(file)) return resolve(await onUploadImage(file))
} }
reject() reject()
//
if (file.type.indexOf('video/') === 0) { if (file.type.indexOf('video/') === 0) {
//
let fn = emitCall('video_event', file, () => {}) let fn = emitCall('video_event', file, () => {})
emit('editor-event', fn) emit('editor-event', fn)
} else { } else {
//
let fn = emitCall('file_event', file, () => {}) let fn = emitCall('file_event', file, () => {})
emit('editor-event', fn) emit('editor-event', fn)
} }
@ -229,29 +285,40 @@ function onEditorUpload(file: File) {
}) })
} }
/**
* 投票事件处理
* @param data 投票数据
*/
function onVoteEvent(data: any) { function onVoteEvent(data: any) {
const msg = emitCall('vote_event', data, (ok: boolean) => { const msg = emitCall('vote_event', data, (ok: boolean) => {
if (ok) { if (ok) {
isShowEditorVote.value = false isShowEditorVote.value = false //
} }
}) })
emit('editor-event', msg) emit('editor-event', msg)
} }
/**
* 表情事件处理
* @param data 表情数据
*/
function onEmoticonEvent(data: any) { function onEmoticonEvent(data: any) {
emoticonRef.value.setShow(false) emoticonRef.value.setShow(false) //
if (data.type == 1) { if (data.type == 1) {
//
const quill = getQuill() const quill = getQuill()
let index = getQuillSelectionIndex() let index = getQuillSelectionIndex()
//
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') { if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
quill.deleteText(0, 1) quill.deleteText(0, 1)
index = 0 index = 0
} }
if (data.img) { if (data.img) {
//
quill.insertEmbed(index, 'emoji', { quill.insertEmbed(index, 'emoji', {
alt: data.value, alt: data.value,
src: data.img, src: data.img,
@ -259,74 +326,87 @@ function onEmoticonEvent(data: any) {
height: '24px' height: '24px'
}) })
} else { } else {
//
quill.insertText(index, data.value) quill.insertText(index, data.value)
} }
//
quill.setSelection(index + 1, 0, 'user') quill.setSelection(index + 1, 0, 'user')
} else { } else {
//
let fn = emitCall('emoticon_event', data.value, () => {}) let fn = emitCall('emoticon_event', data.value, () => {})
emit('editor-event', fn) emit('editor-event', fn)
} }
} }
/**
* 代码事件处理
* @param data 代码数据
*/
function onCodeEvent(data: any) { function onCodeEvent(data: any) {
const msg = emitCall('code_event', data, (ok: boolean) => { const msg = emitCall('code_event', data, (ok: boolean) => {
isShowEditorCode.value = false isShowEditorCode.value = false //
}) })
emit('editor-event', msg) emit('editor-event', msg)
} }
/**
* 文件上传处理
* @param e 上传事件对象
*/
async function onUploadFile(e: any) { async function onUploadFile(e: any) {
let file = e.target.files[0] let file = e.target.files[0]
e.target.value = null e.target.value = null // input
console.log("文件类型"+file.type) console.log("文件类型"+file.type)
if (file.type.indexOf('image/') === 0) { if (file.type.indexOf('image/') === 0) {
console.log("进入图片") console.log("进入图片")
const quill = getQuill() // -
let index = getQuillSelectionIndex() let fn = emitCall('image_event', file, () => {})
emit('editor-event', fn)
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
quill.deleteText(0, 1)
index = 0
}
let src = await onUploadImage(file)
if (src) {
quill.insertEmbed(index, 'image', src)
quill.setSelection(index + 1)
}
return return
} }
if (file.type.indexOf('video/') === 0) { if (file.type.indexOf('video/') === 0) {
console.log("进入视频") console.log("进入视频")
//
let fn = emitCall('video_event', file, () => {}) let fn = emitCall('video_event', file, () => {})
emit('editor-event', fn) emit('editor-event', fn)
} else { } else {
console.log("进入其他") console.log("进入其他")
//
let fn = emitCall('file_event', file, () => {}) let fn = emitCall('file_event', file, () => {})
emit('editor-event', fn) emit('editor-event', fn)
} }
} }
/**
* 录音事件处理
* @param file 录音文件
*/
function onRecorderEvent(file: any) { function onRecorderEvent(file: any) {
emit('editor-event', emitCall('file_event', file)) emit('editor-event', emitCall('file_event', file))
isShowEditorRecorder.value = false isShowEditorRecorder.value = false //
} }
/**
* 粘贴内容处理移除粘贴内容中的样式
* @param node DOM节点
* @param Delta Quill Delta对象
* @returns 处理后的Delta
*/
function onClipboardMatcher(node: any, Delta) { function onClipboardMatcher(node: any, Delta) {
const ops: any[] = [] const ops: any[] = []
Delta.ops.forEach((op) => { Delta.ops.forEach((op) => {
// //
if (op.insert && typeof op.insert === 'string') { if (op.insert && typeof op.insert === 'string') {
ops.push({ ops.push({
insert: op.insert, // insert: op.insert, //
attributes: {} // attributes: {} //
}) })
} else { } else {
ops.push(op) ops.push(op)
@ -337,12 +417,15 @@ function onClipboardMatcher(node: any, Delta) {
return Delta return Delta
} }
/**
* 发送消息处理
* 根据编辑器内容类型发送不同类型的消息
*/
function onSendMessage() { function onSendMessage() {
var delta = getQuill().getContents() var delta = getQuill().getContents()
let data = deltaToMessage(delta) let data = deltaToMessage(delta) // Delta
if (data.items.length === 0||!data.items[0].content.trim()) {
if (data.items.length === 0) { return //
return
} }
switch (data.msgType) { switch (data.msgType) {
@ -351,60 +434,72 @@ function onSendMessage() {
return window['$message'].info('发送内容超长,请分条发送') return window['$message'].info('发送内容超长,请分条发送')
} }
//
emit( emit(
'editor-event', 'editor-event',
emitCall('text_event', data, (ok: any) => { emitCall('text_event', data, (ok: any) => {
ok && getQuill().setContents([], Quill.sources.USER) ok && getQuill().setContents([], Quill.sources.USER) //
}) })
) )
break break
case 3: // case 3: //
//
emit( emit(
'editor-event', 'editor-event',
emitCall( emitCall(
'image_event', 'image_event',
{ ...getImageInfo(data.items[0].content), url: data.items[0].content, size: 10000 }, { ...getImageInfo(data.items[0].content), url: data.items[0].content, size: 10000 },
(ok: any) => { (ok: any) => {
ok && getQuill().setContents([]) ok && getQuill().setContents([]) //
} }
) )
) )
break break
case 12: // case 12: //
//
emit( emit(
'editor-event', 'editor-event',
emitCall('mixed_event', data, (ok: any) => { emitCall('mixed_event', data, (ok: any) => {
ok && getQuill().setContents([]) ok && getQuill().setContents([]) //
}) })
) )
break break
} }
} }
/**
* 编辑器内容改变时的处理
* 保存草稿并触发输入事件
*/
function onEditorChange() { function onEditorChange() {
let delta = getQuill().getContents() let delta = getQuill().getContents()
let text = deltaToString(delta) // Delta
let text = deltaToString(delta)
if (!isEmptyDelta(delta)) { if (!isEmptyDelta(delta)) {
// 稿store
editorDraftStore.items[indexName.value || ''] = JSON.stringify({ editorDraftStore.items[indexName.value || ''] = JSON.stringify({
text: text, text: text,
ops: delta.ops ops: delta.ops
}) })
} else { } else {
// editorDraftStore.items // 稿
delete editorDraftStore.items[indexName.value || ''] delete editorDraftStore.items[indexName.value || '']
} }
//
emit('editor-event', emitCall('input_event', text)) emit('editor-event', emitCall('input_event', text))
} }
/**
* 加载编辑器草稿内容
* 当切换聊天对象时加载对应的草稿
*/
function loadEditorDraftText() { function loadEditorDraftText() {
if (!editor.value) return if (!editor.value) return
// // DOM
setTimeout(() => { setTimeout(() => {
hideMentionDom() hideMentionDom() // @
const quill = getQuill() const quill = getQuill()
@ -415,33 +510,47 @@ function loadEditorDraftText() {
if (draft) { if (draft) {
quill.setContents(JSON.parse(draft)?.ops || []) quill.setContents(JSON.parse(draft)?.ops || [])
} else { } else {
quill.setContents([]) quill.setContents([]) // 稿
} }
//
const index = getQuillSelectionIndex() const index = getQuillSelectionIndex()
quill.setSelection(index, 0, 'user') quill.setSelection(index, 0, 'user')
}, 0) }, 0)
} }
/**
* 处理@成员事件
* @param data @成员数据
*/
function onSubscribeMention(data: any) { function onSubscribeMention(data: any) {
const mention = getQuill().getModule('mention') const mention = getQuill().getModule('mention')
// @
mention.insertItem({ id: data?.id, denotationChar: '@', value: data.value }, true) mention.insertItem({ id: data?.id, denotationChar: '@', value: data.value }, true)
} }
/**
* 处理引用事件
* @param data 引用数据
*/
function onSubscribeQuote(data: any) { function onSubscribeQuote(data: any) {
//
const delta = getQuill().getContents() const delta = getQuill().getContents()
if (delta.ops?.some((item: any) => item.insert.quote)) { if (delta.ops?.some((item: any) => item.insert.quote)) {
return return //
} }
const quill = getQuill() const quill = getQuill()
const index = getQuillSelectionIndex() const index = getQuillSelectionIndex()
//
quill.insertEmbed(0, 'quote', data) quill.insertEmbed(0, 'quote', data)
quill.setSelection(index + 1, 0, 'user') quill.setSelection(index + 1, 0, 'user') //
} }
/**
* 隐藏@成员DOM元素
*/
function hideMentionDom() { function hideMentionDom() {
let el = document.querySelector('.ql-mention-list-container') let el = document.querySelector('.ql-mention-list-container')
if (el) { if (el) {
@ -449,27 +558,54 @@ function hideMentionDom() {
} }
} }
/**
* 处理编辑消息事件
* @param data 消息数据
*/
function onSubscribeEdit(data: any) {
const quill = getQuill()
if (!quill) return
//
quill.setContents([])
//
quill.setText(data.content)
//
const index = quill.getLength() - 1
quill.setSelection(index > 0 ? index : 0, 0, 'user')
}
// 稿
watch(indexName, loadEditorDraftText, { immediate: true }) watch(indexName, loadEditorDraftText, { immediate: true })
//
onMounted(() => { onMounted(() => {
loadEditorDraftText() loadEditorDraftText()
}) })
//
onUnmounted(() => { onUnmounted(() => {
hideMentionDom() hideMentionDom()
}) })
// 线
useEventBus([ useEventBus([
{ name: EditorConst.Mention, event: onSubscribeMention }, { name: EditorConst.Mention, event: onSubscribeMention }, // @
{ name: EditorConst.Quote, event: onSubscribeQuote } { name: EditorConst.Quote, event: onSubscribeQuote }, //
{ name: EditorConst.Edit, event: onSubscribeEdit } //
]) ])
</script> </script>
<template> <template>
<!-- 编辑器容器 -->
<section class="el-container editor"> <section class="el-container editor">
<section class="el-container is-vertical"> <section class="el-container is-vertical">
<!-- 工具栏区域 -->
<header class="el-header toolbar bdr-t"> <header class="el-header toolbar bdr-t">
<div class="tools"> <div class="tools">
<!-- 表情选择器弹出框 -->
<n-popover <n-popover
placement="top-start" placement="top-start"
trigger="click" trigger="click"
@ -489,6 +625,7 @@ useEventBus([
<MeEditorEmoticon @on-select="onEmoticonEvent" /> <MeEditorEmoticon @on-select="onEmoticonEvent" />
</n-popover> </n-popover>
<!-- 工具栏其他功能按钮 -->
<div <div
class="item pointer" class="item pointer"
v-for="nav in navs" v-for="nav in navs"
@ -502,6 +639,7 @@ useEventBus([
</div> </div>
</header> </header>
<!-- 编辑器主体区域 -->
<main class="el-main height100"> <main class="el-main height100">
<QuillEditor <QuillEditor
ref="editor" ref="editor"
@ -514,11 +652,13 @@ useEventBus([
</section> </section>
</section> </section>
<!-- 隐藏的文件上传表单 -->
<form enctype="multipart/form-data" style="display: none"> <form enctype="multipart/form-data" style="display: none">
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" /> <input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" />
<input type="file" ref="uploadFileRef" @change="onUploadFile" /> <input type="file" ref="uploadFileRef" @change="onUploadFile" />
</form> </form>
<!-- 条件渲染的功能组件 -->
<MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" /> <MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" />
<MeEditorCode <MeEditorCode
@ -536,7 +676,7 @@ useEventBus([
<style lang="less" scoped> <style lang="less" scoped>
.editor { .editor {
--tip-bg-color: rgb(241 241 241 / 90%); --tip-bg-color: rgb(241 241 241 / 90%); /* 提示背景颜色 */
height: 100%; height: 100%;
@ -559,7 +699,7 @@ useEventBus([
user-select: none; user-select: none;
.tip-title { .tip-title {
display: none; display: none; /* 默认隐藏提示文字 */
position: absolute; position: absolute;
top: 40px; top: 40px;
left: 0px; left: 0px;
@ -577,7 +717,7 @@ useEventBus([
&:hover { &:hover {
.tip-title { .tip-title {
display: block; display: block; /* 悬停时显示提示文字 */
} }
} }
} }
@ -585,6 +725,7 @@ useEventBus([
} }
} }
/* 暗色模式样式调整 */
html[theme-mode='dark'] { html[theme-mode='dark'] {
.editor { .editor {
--tip-bg-color: #48484d; --tip-bg-color: #48484d;
@ -593,13 +734,16 @@ html[theme-mode='dark'] {
</style> </style>
<style lang="less"> <style lang="less">
/* 全局编辑器样式 */
#editor { #editor {
overflow: hidden; overflow: hidden;
} }
/* 编辑器主体区域样式 */
.ql-editor { .ql-editor {
padding: 8px; padding: 8px;
/* 滚动条样式 */
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 3px; width: 3px;
height: 3px; height: 3px;
@ -611,6 +755,7 @@ html[theme-mode='dark'] {
background-color: transparent; background-color: transparent;
} }
/* 悬停时显示滚动条 */
&:hover { &:hover {
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: var(--im-scrollbar-thumb); background-color: var(--im-scrollbar-thumb);
@ -618,6 +763,7 @@ html[theme-mode='dark'] {
} }
} }
/* 编辑器占位符样式 */
.ql-editor.ql-blank::before { .ql-editor.ql-blank::before {
font-family: font-family:
PingFang SC, PingFang SC,
@ -626,6 +772,7 @@ html[theme-mode='dark'] {
left: 8px; left: 8px;
} }
/* 编辑器中图片样式 */
.ql-snow .ql-editor img { .ql-snow .ql-editor img {
max-width: 100px; max-width: 100px;
border-radius: 3px; border-radius: 3px;
@ -633,6 +780,7 @@ html[theme-mode='dark'] {
margin: 0px 2px; margin: 0px 2px;
} }
/* 图片上传中样式 */
.image-uploading { .image-uploading {
display: flex; display: flex;
width: 100px; width: 100px;
@ -646,15 +794,18 @@ html[theme-mode='dark'] {
} }
} }
/* 表情符号样式 */
.ed-emoji { .ed-emoji {
background-color: unset !important; background-color: unset !important;
} }
/* 编辑器占位符样式 */
.ql-editor.ql-blank::before { .ql-editor.ql-blank::before {
font-style: unset; font-style: unset;
color: #b8b3b3; color: #b8b3b3;
} }
/* 引用卡片样式 */
.quote-card-content { .quote-card-content {
display: flex; display: flex;
background-color: #f6f6f6; background-color: #f6f6f6;
@ -691,6 +842,7 @@ html[theme-mode='dark'] {
} }
} }
/* 暗色模式下的样式调整 */
html[theme-mode='dark'] { html[theme-mode='dark'] {
.ql-editor.ql-blank::before { .ql-editor.ql-blank::before {
color: #57575a; color: #57575a;

View File

@ -48,10 +48,10 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
<input type="file" ref="fileImageRef" accept="image/*" @change="onUpload" /> <input type="file" ref="fileImageRef" accept="image/*" @change="onUpload" />
</form> </form>
<section class="el-container is-vertical section height100"> <section class="el-container is-vertical section height100 p-10px">
<header class="el-header em-header bdr-b"> <!-- <header class="el-header em-header bdr-b">
<span>{{ items[tabIndex].name }}</span> <span>{{ items[tabIndex].name }}</span>
</header> </header> -->
<main class="el-main em-main me-scrollbar me-scrollbar-thumb"> <main class="el-main em-main me-scrollbar me-scrollbar-thumb">
<div class="symbol-box" v-if="tabIndex == 0"> <div class="symbol-box" v-if="tabIndex == 0">
@ -82,7 +82,7 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
</div> </div>
</main> </main>
<footer class="el-footer em-footer tabs"> <!-- <footer class="el-footer em-footer tabs">
<div <div
class="tab pointer" class="tab pointer"
v-for="(item, index) in items" v-for="(item, index) in items"
@ -93,7 +93,7 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
<p class="tip">{{ item.name }}</p> <p class="tip">{{ item.name }}</p>
<img width="20" height="20" :src="item.icon" /> <img width="20" height="20" :src="item.icon" />
</div> </div>
</footer> </footer> -->
</section> </section>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
@ -186,15 +186,20 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
flex-wrap: wrap; flex-wrap: wrap;
.option{ .option{
height: 32px; padding: 7px;
width: 32px; border-radius: 4px;
margin: 2px; &:hover {
font-size: 24px; background-color: #F2F2F2;
}
:deep(.emoji){
height: 22px;
width: 22px;
user-select: none; user-select: none;
transition: all 0.5s; transition: all 0.5s;
&:hover { // &:hover {
transform: scale(1.5); // transform: scale(1.5);
// }
} }
} }
} }

View File

@ -0,0 +1,121 @@
<template>
<div class="fl-tree width-100 fl-mt-md">
<n-tree v-if="state.treeLoading"
block-line
:default-expanded-keys="state.expandedKeys"
:default-selected-keys="state.clickKey"
label-field="name"
key-field="key"
:expand-on-click="true"
:render-label="renderLabel"
:data="state.treeData"
@update:selected-keys="handleSelectTree" />
</div>
</template>
<script setup>
import {
ref,
reactive,
onBeforeMount,
onMounted,
getCurrentInstance,
computed,
defineEmits,
watch,
nextTick,
h
} from "vue";
import { PlusCircleOutlined, MinusCircleOutlined, EditOutlined, PlusOutlined, MinusOutlined, CloseOutlined, CheckOutlined } from '@ant-design/icons-vue';
import treeLabel from "./treelabel.vue";
import { NTree } from 'naive-ui';
const currentInstance = getCurrentInstance();
const { $request } = currentInstance.appContext.config.globalProperties;
let props = defineProps({
data: Object,
refreshCount: Number,
config: Object,
expandedKeys: Array,
clickKey: [String, Number]
})
const state = reactive({
expandedKeys: [],
editTitle: '',
treeData: [],
clickKey: [],
treeLoading: true
});
watch(() => props.refreshCount, () => {
state.clickKey = [props.clickKey]
state.treeLoading = false
nextTick(() => {
state.treeData = props.data
calcDefaultConfig(state.treeData, 1)
state.treeLoading = true
})
});
watch(() => props.expandedKeys, () => {
state.clickKey = [props.clickKey]
state.expandedKeys = props.expandedKeys
}, { deep: true });
onBeforeMount(() => {
state.clickKey = [props.clickKey]
state.treeData = props.data
calcDefaultConfig(state.treeData, 1);
state.expandedKeys = state.treeData.map(item => item.key)
});
onMounted(() => {
});
const emit = defineEmits(["triggerTreeAction", "triggerTreeClick", "triggerTreeDefaultClick"]);
const handleSelectTree = (keys, option, meta) => {
if (keys.length === 1) {
emit('triggerTreeClick', { selectedKey: keys[0], tree: option[0] })
} else {
emit('triggerTreeDefaultClick')
}
}
const renderLabel = (option, checked) => {
return h(
treeLabel,
{
dataRef: option,
checked: checked,
config: props.config,
clickKey: props.clickKey,
onTriggerTreeAction: handleTreeAction
},
{}
)
}
const calcDefaultConfig = (data, level) => {
for (let item of data) {
if (!item.key) {
item.key = item.title + '_' + level;
}
item.edit = false
if (item.children) {
calcDefaultConfig(item.children, level + 1);
}
}
}
const override = ({ option }) => {
if (option.children) {
return "toggleExpand";
}
return "default";
};
const handleTreeAction = ({ type, val }) => {
emit('triggerTreeAction', { type, val })
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,121 @@
<template>
<div class="row items-center">
<div v-if="state.treeData.edit">
<n-input v-model:value="state.editTitle"
style="max-width:200px" />
</div>
<n-popover trigger="hover"
v-else>
<template #trigger>
<div style="max-width:200px"
class="fl-px-sm sf-text-ellipsis">{{ state.treeData.title + '' + state.treeData.staffNum + '' }}</div>
</template>
<div>{{ state.treeData.title }}</div>
</n-popover>
<n-icon :component="CreateOutline"
class="fl-ml-sm"
size="20"
v-if="config?.actions.includes('edit')&&!state.treeData.edit"
@click.stop="handleTreeEdit(state.treeData)" />
<n-icon :component="Remove"
size="20"
v-if="config?.actions.includes('subtraction')&&!state.treeData.edit&&visibleFormItem(config.subtractionShow, state.treeData)"
class="fl-ml-sm"
@click.stop="handleTreeSubtraction(state.treeData)" />
<n-icon :component="Add"
size="20"
v-if="config?.actions.includes('add')&&!state.treeData.edit&&visibleFormItem(config.addShow, state.treeData)"
class="fl-ml-sm"
@click.stop="handleTreeAdd(state.treeData)" />
<drag-outlined v-if="config?.actions.includes('move')&&!state.treeData.edit&&visibleFormItem(config.moveShow, state.treeData)"
class="fl-ml-sm"
@click.stop="handleTreeMove(state.treeData)" />
<!-- <n-icon :component="MoveOutline"
size="20"
v-if="config?.actions.includes('move')&&!state.treeData.edit&&visibleFormItem(config.moveShow, state.treeData)"
class="fl-ml-sm"
@click.stop="handleTreeMove(state.treeData)" /> -->
<n-icon :component="Checkmark"
size="20"
v-if="state.treeData.edit"
class="fl-ml-sm"
@click.stop="handleTreeSave(state.treeData)" />
<n-icon :component="Close"
size="20"
v-if="state.treeData.edit"
class="fl-ml-md"
@click.stop="handleTreeNotSave(state.treeData)" />
</div>
</template>
<script setup>
import {
onBeforeMount,
onMounted,
watch,
reactive
} from "vue";
import {
visibleFormItem,
} from "@/utils/helper/form";
import {
UpOutlined,
DownOutlined,
CloseCircleOutlined,
PlusOutlined,
DragOutlined,
} from "@ant-design/icons-vue";
import { Add, Checkmark, Close, CreateOutline, Remove, MoveOutline } from "@vicons/ionicons5";
import { NPopover, NInput, NIcon } from "naive-ui";
let props = defineProps({
dataRef: Object,
checked: Boolean,
config: Object,
clickKey: [String, Number]
})
const state = reactive({
expandedKeys: [],
editTitle: '',
treeData: [],
});
onBeforeMount(() => {
state.treeData = props.dataRef.option
})
watch(() => props.dataRef.option, (val) => {
state.treeData = props.dataRef.option
}, { deep: true })
onMounted(() => {
})
const emit = defineEmits(["triggerTreeAction", "triggerTreeClick"]);
// const myComponentRef = ref(null);
const handleTreeEdit = () => {
state.editTitle = state.treeData.title
state.treeData.edit = true
// myComponentRef.value.$forceUpdate();
}
const handleTreeAdd = () => {
emit('triggerTreeAction', { type: 'add', val: state.treeData })
}
const handleTreeMove = () => {
emit('triggerTreeAction', { type: 'move', val: state.treeData })
}
const handleTreeSubtraction = () => {
emit('triggerTreeAction', { type: 'subtraction', val: state.treeData })
}
const handleTreeSave = () => {
state.treeData.title = state.editTitle
emit('triggerTreeAction', { type: 'save', val: state.treeData })
}
const handleTreeNotSave = () => {
state.editTitle = ''
emit('triggerTreeAction', { type: 'cancel', val: state.treeData })
}
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
<template>
<span>
<template v-for="(part, index) in parts" :key="index">
<span v-if="part.highlighted" :class="highlightClass">
{{ part.text }}
</span>
<span v-else>{{ part.text }}</span>
</template>
</span>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
text: {
type: String,
required: true,
},
searchText: {
type: String,
default: '',
},
highlightClass: {
type: String,
default: 'highlight',
},
})
const escapedSearchText = computed(() =>
String(props.searchText).replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'),
)
const pattern = computed(() => new RegExp(escapedSearchText.value, 'gi'))
const parts = computed(() => {
if (!props.searchText || !props.text)
return [{ text: props.text, highlighted: false }];
const result = [];
let currentIndex = 0;
const escapedSearchTextValue = escapedSearchText.value;
const searchPattern = new RegExp(`(${escapedSearchTextValue})`, 'gi');
props.text.replace(searchPattern, (match, p1, offset) => {
//
if (currentIndex < offset) {
result.push({ text: props.text.slice(currentIndex, offset), highlighted: false });
}
//
result.push({ text: p1, highlighted: true });
//
currentIndex = offset + p1.length;
return p1; // replace
});
//
if (currentIndex < props.text.length) {
result.push({ text: props.text.slice(currentIndex), highlighted: false });
}
return result;
});
</script>
<style scoped>
.highlight {
color: #7a58de;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,527 @@
<template>
<div
class="search-item"
:class="props?.conditionType ? 'search-item-condition' : ''"
v-if="resultName"
:style="{
margin: props.searchResultKey === 'talk_record_infos_receiver' ? '12px 0 0' : '',
'background-color': props.isClickStay ? '#EEE9F8' : ''
}"
>
<div class="search-item-avatar">
<avatarModule
:mode="props.searchItem?.group_type === 0 ? 1 : 2"
:avatar="avatarImg"
:userName="resultName"
:groupType="props.searchItem?.group_type"
:customStyle="{
width: props?.conditionType ? '32px' : '42px',
height: props?.conditionType ? '32px' : '42px',
margin: props?.conditionType ? '0 9px 0 0' : '0 10px 0 0'
}"
:customTextStyle="{
fontSize: props?.conditionType ? '10px' : '14px',
fontWeight: 'bold',
color: '#fff',
lineHeight: '24px'
}"
></avatarModule>
<div
class="info-tag"
v-if="resultType && !searchRecordDetail"
:style="'border-color:' + resultTypeColor"
>
<span class="text-[10px] font-medium" :style="'color:' + resultTypeColor">
{{ resultType }}
</span>
</div>
</div>
<div class="result-info">
<div class="info-name" :class="searchRecordDetail ? 'info-name-searchRecordDetail' : ''">
<HighlightText
:class="
props?.conditionType
? 'text-[14px] font-medium'
: searchRecordDetail
? 'text-[12px] font-medium'
: 'text-[14px] font-bold'
"
:text="resultName"
:searchText="props.searchText"
/>
<div class="info_num" v-if="groupNum">
<span class="text-[14px] font-medium">
{{ '' + groupNum + '' }}
</span>
</div>
<div v-if="searchRecordDetail && chatRecordCreatedAt">
<span class="text-[12px] font-medium">
{{ chatRecordCreatedAt }}
</span>
</div>
</div>
<div
class="info-detail"
v-if="resultDetail"
:class="searchRecordDetail ? 'info-detail-searchRecordDetail' : ''"
>
<HighlightText
class="text-[12px] font-regular"
:text="resultDetail"
:searchText="props.searchText"
v-if="props.searchItem?.msg_type !== 3 && props.searchItem?.msg_type !== 6"
/>
<div class="message-component-wrapper" v-if="props.searchItem?.msg_type === 3" @click.stop>
<component
:is="MessageComponents[props.searchItem?.msg_type] || 'unknown-message'"
:extra="resultDetail"
:data="props?.searchItem"
/>
</div>
<div class="file-message-wrapper" v-if="props.searchItem?.msg_type === 6" @click.stop>
<div class="condition-each-result-attachments" @click="previewPDF(resultDetail.path)">
<div class="attachment-avatar">
<img :src="resultDetail?.file_avatar" />
</div>
<div class="attachment-info">
<div class="attachment-info-title">
<span class="text-[14px] font-regular">
{{ resultDetail?.name }}
</span>
<span
class="text-[14px] font-regular"
style="color: #999999; flex-shrink: 0; margin: 0 0 0 20px;"
>
{{ resultDetail?.dateTime }}
</span>
</div>
<div class="attachment-sub-info">
<span class="text-[12px] font-regular">
{{ resultDetail?.typeText }}
</span>
<span class="text-[12px] font-regular" style="flex-shrink: 0; margin: 0 0 0 20px;">
{{ resultDetail?.fileSize }}
</span>
</div>
</div>
</div>
</div>
<div class="searchRecordDetail-fastLocal" v-if="searchRecordDetail">
<span>定位到聊天位置</span>
</div>
</div>
</div>
<div class="search-item-pointer" v-if="pointerIconSrc">
<img :src="pointerIconSrc" />
</div>
</div>
</template>
<script setup>
import avatarModule from '@/components/avatar-module/index.vue'
import { ref, watch, computed, onMounted, onUnmounted, reactive, defineProps } from 'vue'
import HighlightText from './highLightText.vue'
import { beautifyTime } from '@/utils/datetime'
import { ChatMsgTypeMapping, MessageComponents } from '@/constant/message'
const props = defineProps({
searchItem: Object | Number,
searchResultKey: {
type: String,
default: ''
},
searchText: {
type: String,
default: ''
}, //
searchRecordDetail: {
type: Boolean,
default: false
}, //
pointerIconSrc: {
type: String,
default: ''
}, //
conditionType: {
type: Number,
default: 0
}, //
isClickStay: {
type: Boolean,
default: false
} //
})
// -
const keyMapping = {
user_infos: { avatar: 'avatar', name: 'nickname' },
group_infos: { avatar: 'avatar', name: 'name', group_num: 'group_num' },
group_member_infos: {
avatar: 'group_avatar',
name: 'group_name',
detailKey: 'user_name',
group_num: 'group_num'
},
combinedGroup: {
avatar: props.searchItem?.groupTempType
? props.searchItem?.groupTempType === 'group_infos'
? 'avatar'
: props.searchItem?.groupTempType === 'group_member_infos'
? 'group_avatar'
: ''
: '',
name: props.searchItem?.groupTempType
? props.searchItem?.groupTempType === 'group_infos'
? 'name'
: props.searchItem?.groupTempType === 'group_member_infos'
? 'group_name'
: ''
: '',
detailKey: props.searchItem?.groupTempType
? props.searchItem?.groupTempType === 'group_member_infos'
? 'user_name'
: ''
: '',
group_num: props.searchItem?.groupTempType
? props.searchItem?.groupTempType === 'group_infos'
? 'group_num'
: props.searchItem?.groupTempType === 'group_member_infos'
? 'group_num'
: ''
: ''
},
general_infos: {
avatar: 'receiver_avatar',
name: 'receiver_name',
detailKey: 'count',
group_num: 'group_num'
},
talk_record_infos: {
avatar: 'user_avatar',
name: 'user_name',
detailKey: 'extra',
created_at: 'created_at'
},
talk_record_infos_receiver: {
avatar: 'receiver_avatar',
name: 'receiver_name',
group_num: 'group_num'
},
search_by_member_condition: {
avatar: 'avatar',
name: 'nickname',
created_at: 'created_at',
msg_type: 'msg_type',
detailKey: 'chatMessageType'
}
}
//key
const getKeyValue = (keys) => {
let keyValue = ''
if (keys) {
keyValue = props?.searchItem ? props?.searchItem[keys] : ''
}
return keyValue
}
//
const avatarImg = computed(() => {
let avatar = getKeyValue(keyMapping[props.searchResultKey]?.avatar)
if (props?.conditionType) {
avatar = props.searchItem.avatar
}
return avatar
})
//
const resultName = computed(() => {
let result_name = getKeyValue(keyMapping[props.searchResultKey]?.name)
if (props?.conditionType) {
result_name = props.searchItem.nickname
}
return result_name
})
//
const imgText = computed(() => {
return resultName.value.length >= 2 ? resultName.value.slice(-2) : resultName.value
})
// -groupType
const groupTypeMapping = {
0: {},
1: {},
2: {
result_type: '部门',
result_type_color: '#377EC6'
},
3: {
result_type: '项目',
result_type_color: '#C1681C'
},
4: {
result_type: '公司',
result_type_color: '#7A58DE'
}
}
//
const groupNum = computed(() => {
return getKeyValue(keyMapping[props.searchResultKey]?.group_num)
})
//tag
const resultType = computed(() => {
return groupTypeMapping[props.searchItem?.group_type]?.result_type
})
//tag
const resultTypeColor = computed(() => {
return groupTypeMapping[props.searchItem?.group_type]?.result_type_color
})
//-
const chatRecordCreatedAt = computed(() => {
let created_at = getKeyValue(keyMapping[props.searchResultKey]?.created_at)
return beautifyTime(created_at)
})
//
const resultDetail = computed(() => {
let result_detail = props.searchItem[keyMapping[props.searchResultKey]?.detailKey]
switch (keyMapping[props.searchResultKey]?.detailKey) {
case 'count':
result_detail = result_detail + '条聊天记录'
break
case 'user_name':
result_detail = '包含:' + result_detail
break
case 'extra':
result_detail = props.searchItem?.extra
break
case 'chatMessageType':
result_detail =
props.searchItem?.msg_type === 1
? props.searchItem?.extra?.content
: props.searchItem?.msg_type === 3 || props.searchItem?.msg_type === 6
? props.searchItem?.extra
: ChatMsgTypeMapping[props.searchItem?.msg_type]
break
default:
result_detail = ''
}
return result_detail
})
const previewPDF = (item) => {
console.log(item)
// if (typeof plus !== 'undefined') {
// downloadAndOpenFile(item)
// } else {
// document.addEventListener('plusready', () => {
// downloadAndOpenFile(item)
// })
// }
window.open(
`${import.meta.env.VITE_PAGE_URL}/office?url=${item}`,
'_blank',
'width=1200,height=900,left=200,top=200,toolbar=no,menubar=no,scrollbars=yes,resizable=yes,location=no,status=no'
)
}
</script>
<style lang="scss" scoped>
.search-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 11px 10px 12px;
cursor: pointer;
position: relative;
.search-item-avatar {
position: relative;
.info-tag {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0px 6px;
border: 1px solid #000;
border-radius: 3px;
flex-shrink: 0;
background-color: #fff;
position: absolute;
bottom: 0;
left: 4px;
span {
line-height: 14px;
}
}
}
.result-info {
width: 100%;
.info-name {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
span {
color: #191919;
line-height: 22px;
}
}
.info-name-searchRecordDetail {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
span {
color: #999999;
line-height: 17px;
}
}
.info-detail {
span {
color: #999999;
line-height: 20px;
}
.file-message-wrapper {
.condition-each-result-attachments {
width: 289px;
height: 62px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 12px 15px;
background-color: #f3f3f3;
border-radius: 4px;
border-bottom: 1px solid #f8f8f8;
box-sizing: border-box;
.attachment-avatar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 38px;
height: 38px;
}
}
.attachment-info {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
margin: 0 0 0 11px;
width: calc(100% - 38px - 11px);
.attachment-info-title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
width: 100%;
span {
line-height: 20px;
color: #191919;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.attachment-sub-info {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
width: 100%;
span {
line-height: 17px;
color: #999999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
}
.info-detail-searchRecordDetail {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
span {
color: #191919;
word-break: break-all;
}
.searchRecordDetail-fastLocal {
display: none;
line-height: 20px;
flex-shrink: 0;
span {
color: #46299d;
font-size: 12px;
font-weight: 400;
line-height: 17px;
}
}
}
}
.search-item-pointer {
width: 5.5px;
height: 9px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
}
}
}
.search-item::after {
content: '';
display: block;
width: 100%;
height: 1px;
position: absolute;
bottom: 0;
left: 10px;
width: calc(100% - 20px);
background-color: #f8f8f8;
}
.search-item-condition {
border: 0;
}
.search-item:hover {
background-color: rgba(70, 41, 157, 0.1);
.info-detail-searchRecordDetail {
.searchRecordDetail-fastLocal {
display: block;
}
}
}
.message-component-wrapper {
width: 154px;
height: 100px;
display: inline-block;
overflow: hidden;
position: relative;
.im-message-video,
.im-message-image,
.image-container {
width: 100% !important;
height: 100% !important;
}
:deep(.n-image) {
width: 100% !important;
height: 100% !important;
}
:deep(img),
:deep(video) {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
}
}
</style>

View File

@ -0,0 +1,833 @@
<template>
<div class="search-list">
<n-infinite-scroll
:style="{ maxHeight: props.searchResultMaxHeight }"
:distance="47"
@load="doLoadMore"
>
<div class="search-result">
<div class="search-result-list">
<div
class="search-result-each-part"
v-for="(searchResultValue, searchResultKey, searchResultIndex) in state.searchResult"
:key="searchResultKey"
>
<div
class="search-result-part"
v-if="
Array.isArray(state?.searchResult[searchResultKey]) &&
state?.searchResult[searchResultKey].length > 0 &&
searchResultKey !== 'group_infos' &&
searchResultKey !== 'group_member_infos'
"
:style="{ margin: props.useCustomTitle ? '0' : '' }"
>
<!-- <div class="result-title" v-if="!props.useCustomTitle">
<span class="text-[14px] font-regular">
{{ getResultKeysValue(searchResultKey) }}
</span>
</div> -->
<slot
name="result-title"
:getResultKeysValue="getResultKeysValue"
:searchResultKey="searchResultKey"
:searchResultIndex="searchResultIndex"
></slot>
<div class="result-list">
<div
class="result-list-each"
v-for="(item, index) in state?.searchResult[searchResultKey]"
:key="index"
>
<searchItem
@click="clickSearchItem(searchResultKey, item)"
v-if="
(searchResultKey === 'user_infos'
? state.userInfosShowAll || (props.listLimit && index < 3)
: searchResultKey === 'combinedGroup'
? state.groupInfosShowAll || (props.listLimit && index < 3)
: props.listLimit && index < 3) || !props.listLimit
"
:searchResultKey="searchResultKey"
:searchItem="item"
:searchText="state.searchText"
:searchRecordDetail="props.searchRecordDetail"
:isClickStay="
props.useClickStay &&
typeof state.clickStayItem === 'string' &&
state.clickStayItem === `${item.talk_type}_${item.receiver_id}`
"
></searchItem>
</div>
</div>
<div
class="result-has-more"
v-if="
getHasMoreResult(searchResultKey) &&
!(
(searchResultKey === 'user_infos' && state.userInfosExpand) ||
(searchResultKey === 'combinedGroup' && state.groupInfosExpand)
)
"
@click="onMoreResultClick(searchResultKey)"
>
<span class="text-[14px] font-regular">
{{ getHasMoreResult(searchResultKey) }}
</span>
</div>
</div>
</div>
</div>
</div>
</n-infinite-scroll>
<!-- <ZPaging
ref="zPaging"
:show-scrollbar="false"
v-model="state.searchResultList"
@query="queryAllSearch"
:default-page-no="state.pageNum"
:default-page-size="props.searchResultPageSize"
:loading-more-default-as-loading="true"
:inside-more="true"
:empty-view-img="searchNoData"
:empty-view-text="'检索您要查找的内容吧~'"
:empty-view-img-style="{ width: '238px', height: '131px' }"
:empty-view-title-style="{
color: '#999999',
margin: '-10px 0 0',
'line-height': '20px',
'font-size': '14px',
'font-weight': 400,
}"
:refresher-enabled="false"
>
<template #top>
<div class="searchRoot">
<customInput
:searchText="state.searchText"
:first_talk_record_infos="state.first_talk_record_infos"
@inputSearchText="inputSearchText"
></customInput>
<span
class="searchRoot_cancelBtn text-[16px] font-medium"
@click="cancelSearch"
>
取消
</span>
</div>
</template>
<div
class="search-record-detail"
v-if="props.searchRecordDetail && !props?.hideFirstRecord"
>
<searchItem
@click="
clickSearchItem(
'talk_record_infos_receiver',
state?.first_talk_record_infos,
)
"
searchResultKey="talk_record_infos_receiver"
:searchItem="state?.first_talk_record_infos"
:pointerIconSrc="pointerIconSrc"
></searchItem>
</div>
<div
class="search-result"
:style="
!state.searchText ? 'align-items:center;justify-content:center;' : ''
"
>
</div>
</ZPaging> -->
</div>
</template>
<script setup>
// import searchNoData from '@/static/image/search/search-no-data.png'
// import customInput from '@/components/custom-input/custom-input.vue'
// import pointerIconSrc from '@/static/image/search/search-item-pointer.png'
// import lodash from 'lodash'
// import { useUserStore } from '@/store'
// const userStore = useUserStore()
// import ZPaging from '@/uni_modules/z-paging/components/z-paging/z-paging.vue'
// import useZPaging from '@/uni_modules/z-paging/components/z-paging/js/hooks/useZPaging.js'
// const zPaging = ref()
// useZPaging(zPaging)
import { NInfiniteScroll } from 'naive-ui'
import searchItem from './searchItem.vue'
import { ref, reactive, defineEmits, defineProps, onMounted, watch } from 'vue'
import { ServeQueryUser, ServeQueryGroup } from '@/api/search'
const emits = defineEmits([
'toMoreResultPage',
'lastIdChange',
'clickSearchItem',
'clickStayItemChange',
'resultTotalCount'
])
const state = reactive({
searchText: '', //
searchResultList: [], //
searchResult: null, //
pageNum: 1, //
uid: 12303, //id
clickStayItem: '', //item
hasMore: true, //
loading: false, //
userInfosExpand: false, //
userInfosLoading: false, //
userInfosLastId: undefined, // last_id
userInfosShowAll: false, // "" true
groupInfosExpand: false, //
groupInfosLoading: false, //
groupInfosLastGroupId: 0, // last_group_id
groupInfosLastMemberId: 0, // last_member_id
groupInfosShowAll: false // "" true
})
const props = defineProps({
searchResultPageSize: {
type: Number,
default: 0
}, //
listLimit: {
type: Boolean,
default: false
}, //
apiParams: {
type: String,
default: ''
}, //
apiRequest: Function, //
searchText: {
type: String,
default: ''
}, //
isPagination: {
type: Boolean,
default: false
}, //
searchRecordDetail: {
type: Boolean,
default: false
}, //
first_talk_record_infos: {
type: Object,
default() {
return {}
}
}, //
hideFirstRecord: {
type: Boolean,
default: false
}, ///
useClickStay: {
type: Boolean,
default: false
}, //使
searchResultMaxHeight: {
type: String,
default: '677px'
}, //
useCustomTitle: {
type: Boolean,
default: false
}, //使
selectItemInList: {
type: String,
default: ''
} //
})
onMounted(() => {
if (props.searchText) {
state.searchText = props.searchText
queryAllSearch()
}
})
//
watch(
() => props.searchResultPageSize,
(newVal, oldVal) => {
queryAllSearch()
}
)
//
watch(
() => props.searchText,
(newVal, oldVal) => {
// state.searchText
state.searchText = newVal
//
state.searchResult = null
//
state.pageNum = 1
//
state.clickStayItem = ''
emits('clickStayItemChange', state.clickStayItem)
//
emits('lastIdChange', 0, 0, 0, '', '')
state.userInfosExpand = false
state.userInfosShowAll = false
state.userInfosLastId = undefined
state.groupInfosExpand = false
state.groupInfosShowAll = false
state.groupInfosLastGroupId = 0
state.groupInfosLastMemberId = 0
queryAllSearch()
}
)
// ES-
const queryAllSearch = (doClearSearchResult) => {
if (doClearSearchResult) {
state.searchResult = null
}
let params = {
key: state.searchText, //
size: props.searchResultPageSize
}
if (props.apiParams) {
let apiParams = JSON.parse(decodeURIComponent(props.apiParams))
params = Object.assign({}, params, apiParams)
}
const resp = props.apiRequest(params)
resp.then(({ code, data }) => {
console.log(data)
if (code == 200) {
if ((data.user_infos || []).length > 0) {
;(data.user_infos || []).forEach((item) => {
item.group_type = 0
})
}
if ((data.group_infos || []).length > 0) {
;(data.group_infos || []).forEach((item) => {
item.group_type = item.type
item.groupTempType = 'group_infos'
})
}
if ((data.group_member_infos || []).length > 0) {
;(data.group_member_infos || []).forEach((item) => {
item.groupTempType = 'group_member_infos'
})
}
if ((data.talk_record_infos || []).length > 0) {
let receiverInfo = JSON.parse(JSON.stringify(data.talk_record_infos[0]))
if (receiverInfo.talk_type === 1) {
//
if (receiverInfo.user_id === state.uid) {
//
}
if (receiverInfo.receiver_id === state.uid) {
//
let temp_id = receiverInfo.receiver_id
let temp_name = receiverInfo.receiver_name
let temp_avatar = receiverInfo.receiver_avatar
receiverInfo.receiver_id = receiverInfo.user_id
receiverInfo.receiver_name = receiverInfo.user_name
receiverInfo.receiver_avatar = receiverInfo.user_avatar
receiverInfo.user_id = temp_id
receiverInfo.user_name = temp_name
receiverInfo.user_avatar = temp_avatar
}
}
state.first_talk_record_infos = Object.assign(
{},
state.first_talk_record_infos,
receiverInfo
)
;(data.talk_record_infos || []).forEach((item) => {
item.group_type = 0
})
}
let tempGeneral_infos = Array.isArray(data.general_infos)
? [...data.general_infos]
: data.general_infos
delete data.general_infos
data.combinedGroup = (data.group_infos || []).concat(data.group_member_infos || [])
data.general_infos = tempGeneral_infos
//
let isEmpty = true
let dataKeys = Object.keys(data)
let paginationKey = ''
dataKeys.forEach((item) => {
if (Array.isArray(data[item]) && data[item].length > 0) {
paginationKey = item
isEmpty = false
}
})
if (isEmpty) {
if (state.pageNum === 1) {
//
state.searchResult = null
// zPaging.value?.complete([])
} else {
//
// zPaging.value?.complete(state.searchResult ? [state.searchResult] : [])
}
} else {
if (props.isPagination) {
if (state.pageNum === 1) {
//
state.searchResult = data
} else {
//
data[paginationKey] = (state.searchResult?.[paginationKey] || []).concat(
data[paginationKey]
)
state.searchResult = data
}
emits(
'lastIdChange',
data.last_id,
data.last_group_id,
data.last_member_id,
data.last_receiver_user_name,
data.last_receiver_group_name
)
let total = data.count
if (props.searchRecordDetail) {
if (state?.first_talk_record_infos?.talk_type === 1) {
total = data.user_record_count
} else if (state?.first_talk_record_infos?.talk_type === 2) {
total = data.group_record_count
}
let noMoreSearchResultRecord = true
if (
Object.keys(data).includes('talk_record_infos') &&
state.searchResult['talk_record_infos']?.length > 0
) {
//
if (state.searchResult['talk_record_infos']?.length < total) {
noMoreSearchResultRecord = false
}
}
if (noMoreSearchResultRecord) {
state.hasMore = false
} else {
state.hasMore = true
}
} else {
let noMoreSearchResultUser = true
let noMoreSearchResultGroup = true
let noMoreSearchResultGeneral = true
if (
Object.keys(data).includes('user_infos') &&
state.searchResult['user_infos']?.length > 0
) {
//
if (state.searchResult['user_infos']?.length < total) {
noMoreSearchResultUser = false
}
}
if (
Object.keys(data).includes('group_member_infos' || 'group_infos') &&
state.searchResult['combinedGroup']?.length > 0
) {
//
if (state.searchResult['combinedGroup']?.length < total) {
noMoreSearchResultGroup = false
}
}
if (
Object.keys(data).includes('general_infos') &&
state.searchResult['general_infos']?.length > 0
) {
//
if (state.searchResult['general_infos']?.length < total) {
noMoreSearchResultGeneral = false
}
}
if (noMoreSearchResultUser && noMoreSearchResultGroup && noMoreSearchResultGeneral) {
state.hasMore = false
} else {
state.hasMore = true
}
}
emits('resultTotalCount', total)
// zPaging.value?.completeByTotal([data], total)
} else {
state.searchResult = data
// zPaging.value?.complete([data])
}
}
state.pageNum = state.pageNum + 1
// userInfosLastId
if (typeof data.last_id !== 'undefined') {
state.userInfosLastId = data.last_id
} else {
state.userInfosLastId = undefined
}
} else {
if (state.pageNum === 1) {
//
state.searchResult = null
// zPaging.value?.complete([])
} else {
//
// zPaging.value?.complete(state.searchResult ? [state.searchResult] : [])
}
}
})
resp.catch(() => {
if (state.pageNum === 1) {
//
state.searchResult = null
// zPaging.value?.complete([])
} else {
//
// zPaging.value?.complete(state.searchResult ? [state.searchResult] : [])
}
})
return resp
}
//
const cancelSearch = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({
delta: 1
})
} else {
uni.reLaunch({
url: '/pages/index/index'
})
}
}
//key
const getResultKeysValue = (keys) => {
let resultKey = ''
switch (keys) {
case 'user_infos':
resultKey = '通讯录'
break
case 'group_infos':
resultKey = '群聊'
break
case 'group_member_infos':
resultKey = '群聊'
break
case 'combinedGroup':
resultKey = '群聊'
break
case 'general_infos':
resultKey = '聊天记录'
break
case 'talk_record_infos':
resultKey = '相关聊天记录'
break
default:
resultKey = ''
}
return resultKey
}
//
const getHasMoreResult = (searchResultKey) => {
let has_more_result = ''
switch (searchResultKey) {
case 'user_infos':
if (state.searchResult['user_count'] && state.searchResult['user_count'] > 3) {
has_more_result = '更多通讯录'
}
break
case 'group_infos':
if (state.searchResult['group_count'] && state.searchResult['group_count'] > 3) {
has_more_result = '更多群聊'
}
break
case 'group_member_infos':
if (state.searchResult['group_count'] && state.searchResult['group_count'] > 3) {
has_more_result = '更多群聊'
}
break
case 'combinedGroup':
if (state.searchResult['group_count'] && state.searchResult['group_count'] > 3) {
has_more_result = '更多群聊'
}
break
case 'general_infos':
if (state.searchResult['record_count'] && state.searchResult['record_count'] >= 3) {
has_more_result = '更多聊天记录'
}
break
default:
}
return has_more_result
}
//
const toMoreResultPage = (searchResultKey) => {
emits('toMoreResultPage', searchResultKey, state.searchText)
}
//
const clickSearchItem = (searchResultKey, searchItem) => {
console.log(searchResultKey, searchItem)
if (props.useClickStay) {
state.clickStayItem = searchItem.talk_type + '_' + searchItem.receiver_id
} else {
state.clickStayItem = ''
}
emits('clickStayItemChange', state.clickStayItem)
let talk_type = searchItem.talk_type
let receiver_id = searchItem.receiver_id
if (searchResultKey === 'user_infos') {
talk_type = 1
receiver_id = searchItem.id
} else if (searchResultKey === 'combinedGroup') {
talk_type = searchItem.type || 2
receiver_id = searchItem.group_id || searchItem.id
} else if (searchResultKey === 'general_infos') {
if (searchItem.talk_type === 1) {
if (searchItem.user_id === state.uid) {
//
}
if (searchItem.receiver_id === state.uid) {
//
let temp_id = searchItem.receiver_id
let temp_name = searchItem.receiver_name
let temp_avatar = searchItem.receiver_avatar
searchItem.receiver_id = searchItem.user_id
searchItem.receiver_name = searchItem.user_name
searchItem.receiver_avatar = searchItem.user_avatar
searchItem.user_id = temp_id
searchItem.user_name = temp_name
searchItem.user_avatar = temp_avatar
}
}
}
emits(
'clickSearchItem',
state.searchText,
searchResultKey,
talk_type,
receiver_id,
encodeURIComponent(JSON.stringify(searchItem))
)
}
//
const doLoadMore = (doClearSearchResult) => {
if (
state.userInfosLoading ||
state.userInfosShowAll ||
state.groupInfosShowAll // queryAllSearch
) {
return
}
if (!state.hasMore || state.loading) {
return
}
state.loading = true
queryAllSearch(doClearSearchResult).finally(() => {
state.loading = false
})
}
watch(
() => props.selectItemInList,
(newVal, oldVal) => {
if (newVal) {
const selectedItem = JSON.parse(decodeURIComponent(newVal))
clickSearchItem('general_infos', selectedItem)
}
},
{
deep: true,
immediate: true
}
)
// last_id
async function loadMoreUserInfos() {
if (state.userInfosLoading) return
state.userInfosLoading = true
try {
let params = {
key: state.searchText,
last_id: state.userInfosLastId,
size: 10
}
const resp = await ServeQueryUser(params)
if (resp.code === 200 && Array.isArray(resp.data.user_infos)) {
if (!state.userInfosLastId) {
//
state.searchResult = {
...state.searchResult,
user_infos: resp.data.user_infos
}
} else {
//
state.searchResult = {
...state.searchResult,
user_infos: (state.searchResult.user_infos || []).concat(resp.data.user_infos)
}
}
state.userInfosLastId = resp.data.last_id
//
if (
!resp.data.last_id ||
(Array.isArray(resp.data.user_infos) && resp.data.user_infos.length < 10)
) {
state.userInfosExpand = true
}
}
} finally {
state.userInfosLoading = false
}
}
// "" ""
function onMoreResultClick(searchResultKey) {
if (searchResultKey === 'user_infos') {
state.userInfosShowAll = true
loadMoreUserInfos()
} else if (searchResultKey === 'combinedGroup') {
state.groupInfosShowAll = true
loadMoreGroupInfos()
} else {
emits('toMoreResultPage', searchResultKey, state.searchText)
}
}
// last_id
async function loadMoreGroupInfos() {
if (state.groupInfosLoading) return
state.groupInfosLoading = true
try {
let params = {
key: state.searchText,
last_group_id: state.groupInfosLastGroupId,
last_member_id: state.groupInfosLastMemberId,
size: 10
}
const resp = await ServeQueryGroup(params)
if (resp.code === 200) {
const groupInfos = Array.isArray(resp.data.group_infos) ? resp.data.group_infos : []
const groupMemberInfos = Array.isArray(resp.data.group_member_infos)
? resp.data.group_member_infos
: []
// groupTempType
groupInfos.forEach((item) => {
item.groupTempType = 'group_infos'
item.group_type = item.type //
})
groupMemberInfos.forEach((item) => {
item.groupTempType = 'group_member_infos'
})
const isFirstLoad =
(!state.groupInfosLastGroupId && !state.groupInfosLastMemberId) ||
(state.groupInfosLastGroupId === 0 && state.groupInfosLastMemberId === 0)
if (isFirstLoad) {
//
state.searchResult = {
...state.searchResult,
group_infos: groupInfos,
group_member_infos: groupMemberInfos,
combinedGroup: groupInfos.concat(groupMemberInfos)
}
} else {
//
const allGroupInfos = (state.searchResult.group_infos || []).concat(groupInfos)
const allGroupMemberInfos = (state.searchResult.group_member_infos || []).concat(
groupMemberInfos
)
state.searchResult = {
...state.searchResult,
group_infos: allGroupInfos,
group_member_infos: allGroupMemberInfos,
combinedGroup: allGroupInfos.concat(allGroupMemberInfos)
}
}
state.groupInfosLastGroupId = resp.data.last_group_id
state.groupInfosLastMemberId = resp.data.last_member_id
//
const noMoreData =
(!groupInfos.length && !groupMemberInfos.length) ||
(resp.data.last_group_id === 0 && resp.data.last_member_id === 0)
if (noMoreData) {
state.groupInfosExpand = true
}
}
} finally {
state.groupInfosLoading = false
}
}
</script>
<style lang="scss" scoped>
.search-list {
.searchRoot {
padding: 10px 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
.searchRoot_cancelBtn {
line-height: 22px;
color: #46299d;
margin: 0 0 0 10px;
flex-shrink: 0;
}
}
.search-record-detail {
padding: 0 25px;
}
.search-result {
width: 100%;
// padding: 0 10px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
box-sizing: border-box;
.search-result-list {
width: 100%;
// padding: 0 10px;
.search-result-part {
// margin: 18px 0 0;
.result-title {
padding: 0 10px 5px;
border-bottom: 1px solid #f8f8f8;
span {
line-height: 20px;
color: #999999;
}
}
.result-has-more {
padding: 10px;
border-bottom: 1px solid #f8f8f8;
cursor: pointer;
span {
color: #191919;
line-height: 20px;
}
}
.result-has-more:hover {
background-color: rgba(70, 41, 157, 0.1);
}
}
}
}
}
</style>

View File

@ -5,43 +5,126 @@ import { ServeGetForwardRecords } from '@/api/chat'
import { MessageComponents } from '@/constant/message' import { MessageComponents } from '@/constant/message'
import { ITalkRecord } from '@/types/chat' import { ITalkRecord } from '@/types/chat'
import { useInject } from '@/hooks' import { useInject } from '@/hooks'
import customModal from '@/components/common/customModal.vue'
const emit = defineEmits(['close']) import { voiceToText } from '@/api/chat.js'
import { parseTime } from '@/utils/datetime'
const props = defineProps({ const props = defineProps({
msgId: { msgId: {
type: String, type: String,
required: true required: true
} },
createdAt: {
type: String,
required: false
},
modalTitle: {
type: String,
required: true
},
}) })
const isShow=defineModel<boolean>('show')
const { showUserInfoModal } = useInject() const { showUserInfoModal } = useInject()
const isShow = ref(true)
const items = ref<ITalkRecord[]>([]) const items = ref<ITalkRecord[]>([])
const title = ref('会话记录') const title = ref(props?.modalTitle || '会话记录')
const onMaskClick = () => { const onMaskClick = () => {
emit('close') isShow.value=false
} }
const onLoadData = () => { const onLoadData = () => {
ServeGetForwardRecords({ ServeGetForwardRecords({
msg_id: props.msgId msg_id: props.msgId,
biz_date: parseTime(new Date(props.createdAt || ''), '{y}{m}')
}).then((res) => { }).then((res) => {
if (res.code == 200) { if (res.code == 200) {
items.value = res.data.items || [] items.value = res.data.items || []
title.value = `会话记录(${items.value.length})` // title.value = `(${items.value.length})`
} }
}) })
} }
const dropdown=ref({
show:false,
x:'',
y:'',
options:[] as any,
item:{} as ITalkRecord,
})
const onConvertText =async (data: ITalkRecord) => {
data.is_convert_text = 1
const res = await voiceToText({msgId:data.msg_id,voiceUrl:data.extra.url})
if(res.code == 200){
data.extra.content = res.data.convText
}
}
const onloseConvertText=(data: ITalkRecord)=>{
data.is_convert_text = 0
}
const evnets = {
convertText: onConvertText,
closeConvertText:onloseConvertText
}
const onContextMenuHandle=(key:string)=>{
evnets[key] && evnets[key](dropdown.value.item)
closeDropdownMenu()
}
const closeDropdownMenu=()=>{
dropdown.value.show=false
}
onMounted(() => { onMounted(() => {
onLoadData() onLoadData()
}) })
const onContextMenu = (e:any,item: ITalkRecord) => {
dropdown.value.show=true
dropdown.value.x=e.clientX
dropdown.value.y=e.clientY
if(item.is_convert_text === 1){
dropdown.value.options=[{ label: '关闭转文字', key: 'closeConvertText' }]
}else{
dropdown.value.options=[{ label: '转文字', key: 'convertText' }]
}
dropdown.value.item=item
}
</script> </script>
<template> <template>
<n-modal <customModal :closable="false" customCloseBtn v-model:show="isShow" :title="title" style="width: 997px;background-color: #F9F9FD;" :on-after-leave="onMaskClick">
<template #content>
<div class="main-box bg-#fff me-scrollbar me-scrollbar-thumb">
<Loading v-if="items.length === 0" />
<div v-for="item in items" :key="item.msg_id" class="message-item">
<div class="left-box pointer" @click="showUserInfoModal(item.erp_user_id)">
<im-avatar :src="item.avatar" :size="38" :username="item.nickname" />
</div>
<div class="right-box">
<div class="msg-header">
<span class="name">{{ item.nickname }}</span>
<span class="time"> {{ item.created_at }}</span>
</div>
<component
@contextmenu.prevent="onContextMenu($event,item)"
:is="MessageComponents[item.msg_type] || 'unknown-message'"
:extra="item.extra"
:data="item"
/>
</div>
</div>
</div>
<!-- 右键菜单 -->
<n-dropdown :show="dropdown.show" :x="dropdown.x" :y="dropdown.y" style="width: 142px;" :options="dropdown.options"
@select="onContextMenuHandle" @clickoutside="closeDropdownMenu" />
</template>
</customModal>
<!-- <n-modal
v-model:show="isShow" v-model:show="isShow"
preset="card" preset="card"
:title="title" :title="title"
@ -80,7 +163,7 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</n-modal> </n-modal> -->
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
@ -94,10 +177,12 @@ onMounted(() => {
min-height: 38px; min-height: 38px;
display: flex; display: flex;
margin-bottom: 10px; margin-bottom: 10px;
padding: 5px 15px; padding: 24px 42px;
.im-message-text{
background-color: #fff;
}
.left-box { .left-box {
width: 30px; width: 38px;
display: flex; display: flex;
user-select: none; user-select: none;
padding-top: 8px; padding-top: 8px;

View File

@ -3,7 +3,7 @@ import { ref, reactive } from 'vue'
import { PlayOne, PauseOne } from '@icon-park/vue-next' import { PlayOne, PauseOne } from '@icon-park/vue-next'
import { ITalkRecordExtraAudio, ITalkRecord } from '@/types/chat' import { ITalkRecordExtraAudio, ITalkRecord } from '@/types/chat'
defineProps<{ const props = defineProps<{
extra: ITalkRecordExtraAudio extra: ITalkRecordExtraAudio
data: ITalkRecord data: ITalkRecord
maxWidth?: Boolean maxWidth?: Boolean
@ -18,7 +18,8 @@ const state = reactive({
progress: 0, progress: 0,
duration: 0, duration: 0,
currentTime: 0, currentTime: 0,
loading: true loading: true,
showText: false
}) })
const onPlay = () => { const onPlay = () => {
@ -40,6 +41,12 @@ const onCanplay = () => {
state.duration = audioRef.value.duration state.duration = audioRef.value.duration
durationDesc.value = formatTime(parseInt(audioRef.value.duration)) durationDesc.value = formatTime(parseInt(audioRef.value.duration))
state.loading = false state.loading = false
if (props.data.is_convert_text === 1 && props.data.extra.content) {
setTimeout(() => {
state.showText = true
}, 300)
}
} }
const onError = (e: any) => { const onError = (e: any) => {
@ -61,17 +68,13 @@ const formatTime = (value: number = 0) => {
return '-' return '-'
} }
const minutes = Math.floor(value / 60) return `${Math.floor(value)}"`
let seconds = value
if (minutes > 0) {
seconds = Math.floor(value - minutes * 60)
}
return `${minutes}'${seconds}"`
} }
</script> </script>
<template> <template>
<div class="im-message-audio"> <div class="pointer w-200px bg-#F3F4FD rounded-10px px-11px">
<div class="im-message-audio h-44px">
<aTrumpet :isPlay="false" color="black" :size="30"></aTrumpet>
<audio <audio
ref="audioRef" ref="audioRef"
preload="auto" preload="auto"
@ -85,33 +88,53 @@ const formatTime = (value: number = 0) => {
<div class="play"> <div class="play">
<div class="btn pointer" @click.stop="onPlay"> <div class="btn pointer" @click.stop="onPlay">
<n-icon :size="18" :component="state.isAudioPlay ? PauseOne : PlayOne" /> <!-- <n-icon :size="18" :component="state.isAudioPlay ? PauseOne : PlayOne" /> -->
<img
v-if="!state.isAudioPlay"
src="@/assets/image/yuyin.png"
class="w-[16px] h-[16px]"
alt=""
/>
<img v-else src="@/assets/image/bofang.png" class="w-[16px] h-[16px]" alt="" />
</div> </div>
</div> </div>
<div class="desc"> <!-- <div class="desc">
<span class="line" v-for="i in 23" :key="i"></span> <span class="line" v-for="i in 23" :key="i"></span>
<span <span
class="indicator" class="indicator"
:style="{ left: state.progress + '%' }" :style="{ left: state.progress + '%' }"
v-show="state.progress > 0" v-show="state.progress > 0"
></span> ></span>
</div> -->
<!-- <div class="time">{{ durationDesc }}</div> -->
<div>{{ durationDesc.split('"')[0] }}s</div>
</div> </div>
<div class="time">{{ durationDesc }}</div>
<transition name="expand">
<div
class="text-container py-12px border-t-1px border-t-solid border-t-#E2E2EB"
v-if="data.is_convert_text === 1"
>
<div
class="flex justify-center items-center"
v-if="data.is_convert_text === 1 && !data.extra.content"
>
<n-spin :stroke-width="3" size="small" />
</div>
<transition name="fade">
<div class="text-content" v-if="data.extra.content">{{ data.extra.content }}</div>
</transition>
</div>
</transition>
</div> </div>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.im-message-audio { .im-message-audio {
--audio-bg-color: #f5f5f5; --audio-bg-color: #f5f5f5;
--audio-btn-bg-color: #ffffff; --audio-btn-bg-color: #ffffff;
width: 200px;
height: 45px;
border-radius: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
background-color: var(--audio-bg-color);
> div { > div {
display: flex; display: flex;
align-items: center; align-items: center;
@ -126,12 +149,13 @@ const formatTime = (value: number = 0) => {
.btn { .btn {
width: 26px; width: 26px;
height: 26px; height: 26px;
background-color: var(--audio-btn-bg-color); // background-color: var(--audio-btn-bg-color);
border-radius: 50%; border-radius: 50%;
color: rgb(24, 24, 24); color: rgb(24, 24, 24);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.3s ease;
} }
} }
@ -230,6 +254,7 @@ const formatTime = (value: number = 0) => {
height: 70%; height: 70%;
width: 1px; width: 1px;
background-color: #9b9595; background-color: #9b9595;
transition: left 0.1s linear;
} }
} }
@ -241,6 +266,40 @@ const formatTime = (value: number = 0) => {
} }
} }
.expand-enter-active,
.expand-leave-active {
transition: all 0.5s ease;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
padding: 0;
border-top-width: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.text-container {
overflow: hidden;
transition: all 0.2s ease;
}
.text-content {
line-height: 1.5;
transition: all 0.2s ease;
}
html[theme-mode='dark'] { html[theme-mode='dark'] {
.im-message-audio { .im-message-audio {
--audio-bg-color: #2c2c32; --audio-bg-color: #2c2c32;

View File

@ -1,118 +1,281 @@
<script lang="ts" setup> <script setup>
import { fileFormatSize } from '@/utils/strings' import { fileFormatSize } from '@/utils/strings'
import { download, getFileNameSuffix } from '@/utils/functions' import { ref, computed } from 'vue'
import { ITalkRecordExtraFile, ITalkRecord } from '@/types/chat' import { useUploadsStore } from '@/store'
import pptText from '@/assets/image/ppt-text.png'
import excelText from '@/assets/image/excel-text.png'
import wordText from '@/assets/image/word-text.png'
import pdfText from '@/assets/image/pdf-text.png'
import fileText from '@/assets/image/file-text.png'
import { ArrowDownload16Filled } from '@vicons/fluent'
import { download } from '@/utils/functions.js'
//
const props = defineProps({
//
extra: {
type: Object,
required: true
},
//
data: {
type: Object,
required: true
},
// 使
maxWidth: {
type: Boolean,
default: false
}
})
defineProps<{ const uploadsStore = useUploadsStore()
extra: ITalkRecordExtraFile const isPlaying = ref(false)
data: ITalkRecord
maxWidth?: Boolean //
}>() const fileTypes = {
PDF: { icon: pdfText, color: '#DE4E4E', type: 'PDF' },
PPT: { icon: pptText, color: '#B74B2B', type: 'PPT' },
EXCEL: { icon: excelText, color: '#3C7F4B', type: 'EXCEL' },
WORD: { icon: wordText, color: '#2750B2', type: 'WORD' },
DEFAULT: { icon: fileText, color: '#747474', type: '文件' }
}
// Excel
const EXCEL_EXTENSIONS = ['XLS', 'XLSX', 'CSV']
// Word
const WORD_EXTENSIONS = ['DOC', 'DOCX', 'RTF', 'DOT', 'DOTX']
// PPT
const PPT_EXTENSIONS = ['PPT', 'PPTX', 'PPS', 'PPSX']
//
const fileInfo = computed(() => {
const extension = getFileExtension(props.extra.path)
if (EXCEL_EXTENSIONS.includes(extension)) {
return fileTypes.EXCEL
}
if (WORD_EXTENSIONS.includes(extension)) {
return fileTypes.WORD
}
if (PPT_EXTENSIONS.includes(extension)) {
return fileTypes.PPT
}
return fileTypes[extension] || fileTypes.DEFAULT
})
//
const canPreview = computed(() => {
const extension = getFileExtension(props.extra.path)
return extension === 'PDF' ||
EXCEL_EXTENSIONS.includes(extension) ||
WORD_EXTENSIONS.includes(extension) ||
PPT_EXTENSIONS.includes(extension)
})
//
function getFileExtension(filepath) {
const parts = filepath?.split('.')
return parts?.length > 1 ? parts?.pop()?.toUpperCase() : ''
}
//
const togglePlay = () => {
isPlaying.value = !isPlaying.value
if (props.extra.is_uploading && props.extra.upload_id) {
const action = isPlaying.value ? 'pauseUpload' : 'resumeUpload'
uploadsStore[action](props.extra.upload_id)
}
}
// SVG
const radius = 9
const circumference = computed(() => 2 * Math.PI * radius)
const strokeDashoffset = computed(() =>
circumference.value * (1 - (props.extra.percentage || 0) / 100)
)
//
const handleClick = () => {
//
if(!props.extra.is_uploading) {
if(canPreview.value){
window.open(
`${import.meta.env.VITE_PAGE_URL}/office?url=${props.extra.path}`,
'_blank',
'width=1200,height=900,left=200,top=200,toolbar=no,menubar=no,scrollbars=yes,resizable=yes,location=no,status=no'
);
}else{
window['$message'].warning('暂不支持在线预览该类型文件')
}
}
}
function downloadFileWithProgress(resourceUrl, filename) {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = resourceUrl;
document.body.appendChild(iframe);
setTimeout(() => {
document.body.removeChild(iframe);
}, 60000);
}
//
const handleDownload = () => {
downloadFileWithProgress(props.extra.path,props.extra.name)
}
</script> </script>
<template> <template>
<section class="file-message"> <div class="file-message flex flex-col can-preview" @click="handleClick">
<div class="main"> <!-- 文件头部信息 -->
<div class="ext">{{ getFileNameSuffix(extra.name) }}</div> <div class="file-header">
<div class="file-box"> <!-- 文件名 -->
<p class="info"> <div class="file-name">{{ extra.name }}</div>
<span class="name">{{ extra.name }}</span> <!-- 文件图标区域 -->
<span class="size">({{ fileFormatSize(extra.size) }})</span> <div class="file-icon-container">
</p> <img class="file-icon" :src="fileInfo.icon" alt="文件图标">
<p class="notice">文件已成功发送, 文件助手永久保存</p>
<!-- 上传进度圆环 - 上传状态 -->
<div v-if="extra.is_uploading&&extra.percentage!==-1" class="progress-overlay">
<div class="circle-progress-container" @click.stop="togglePlay">
<svg class="circle-progress" width="20" height="20" viewBox="0 0 20 20">
<!-- 底色圆环 -->
<circle
cx="10"
cy="10"
r="9"
fill="transparent"
stroke="#EEEEEE"
stroke-width="2"
/>
<!-- 进度圆环 -->
<circle
cx="10"
cy="10"
r="9"
fill="transparent"
:stroke="fileInfo.color"
stroke-width="2"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
transform="rotate(-90 10 10)"
class="progress-circle"
/>
<!-- 暂停/播放图标 -->
<g v-if="isPlaying" class="play-icon">
<rect x="6" y="6" width="8" height="8" :fill="fileInfo.color" />
</g>
<g v-else class="pause-icon">
<rect x="7" y="5" width="2" height="10" :fill="fileInfo.color" />
<rect x="11" y="5" width="2" height="10" :fill="fileInfo.color" />
</g>
</svg>
</div>
</div>
</div>
</div>
<!-- 文件大小信息 -->
<div class="flex justify-between items-center grow-1">
<div class="file-size">{{ fileFormatSize(extra.size) }}</div>
<div class="flex items-center" v-if="!extra.is_uploading">
<div class="flex items-center" @click.stop="handleDownload"> <img class="w-11.7px h-11.74px mr-7px" src="@/assets/image/dofd.png" alt=""> <span class="text-12px text-#46299D">下载</span></div>
</div> </div>
</div> </div>
<div class="footer">
<a @click="download(data.msg_id)">下载</a>
<a>在线预览</a>
</div> </div>
</section>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.file-message { .file-message {
width: 250px; width: 243px;
min-height: 85px; background-color: #fff;
padding: 10px; height: 110px;
border-radius: 10px; border-radius: 8px;
border: 1px solid var(--im-message-border-color); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 0 14px;
.main { }
height: 45px;
.can-preview {
cursor: pointer;
&:hover {
background-color: #f9f9f9;
}
}
.file-header {
display: flex; display: flex;
flex-direction: row; padding: 14px 5px 14px 0;
margin-top: 5px; justify-content: space-between;
width: 100%;
border-bottom: 1px solid #EEEEEE;
}
.ext { .file-name {
height: 50px;
color: #1A1A1A;
font-size: 14px;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.file-icon-container {
height: 48px;
position: relative;
}
.file-icon {
width: 48px;
height: 48px;
}
.progress-overlay {
background-color: #fff;
position: absolute;
top: 6px;
left: 11px;
width: 30px;
height: 30px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 45px; }
height: 45px;
color: #ffffff; .file-size {
background: #49a4ff; color: #747474;
border-radius: 5px;
font-size: 12px; font-size: 12px;
} }
.file-box { .circle-progress-container {
flex: 1 1; width: 20px;
height: 45px; height: 20px;
margin-left: 10px; position: relative;
overflow: hidden;
.info {
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
height: 24px;
font-size: 14px;
.name {
flex: 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.size {
font-size: 12px;
color: #cac6c6;
flex-shrink: 0;
}
}
.notice {
height: 25px;
line-height: 25px;
font-size: 12px;
color: #929191;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.footer {
height: 30px;
line-height: 37px;
text-align: right;
font-size: 12px;
border-top: 1px solid var(--border-color);
margin-top: 10px;
a {
margin: 0 3px;
user-select: none;
cursor: pointer; cursor: pointer;
color: var(--im-text-color); }
&:hover { .circle-progress {
color: royalblue; transform: rotate(-90deg);
transform-origin: center;
} }
.progress-circle {
transition: stroke-dashoffset 0.3s ease;
} }
.pause-icon, .play-icon {
transform-origin: center;
} }
.pause-icon {
transform: rotate(90deg);
} }
</style> </style>

View File

@ -12,7 +12,13 @@ const props = defineProps<{
const isShowRecord = ref(false) const isShowRecord = ref(false)
const title = computed(() => { const title = computed(() => {
return [...new Set(props.extra.records.map((v) => v.nickname))].join('、') const uniqueNames = [...new Set(props.extra.records.map(v => v.nickname))];
if (uniqueNames.length <= 2) {
return uniqueNames.join('和');
} else {
return uniqueNames.slice(0, 2).join('和') + '等';
}
// return [...new Set(props.extra.records.map((v) => v.nickname))].join('')
}) })
const onClick = () => { const onClick = () => {
@ -21,7 +27,7 @@ const onClick = () => {
</script> </script>
<template> <template>
<section class="im-message-forward pointer" @click="onClick"> <section class="im-message-forward pointer" @click="onClick">
<div class="title">{{ title }} 的会话记录</div> <div class="title">{{ extra.forward_name || title}}的会话记录</div>
<div class="list" v-for="(record, index) in extra.records" :key="index"> <div class="list" v-for="(record, index) in extra.records" :key="index">
<p> <p>
<span>{{ record.nickname }}: </span> <span>{{ record.nickname }}: </span>
@ -33,7 +39,7 @@ const onClick = () => {
<span>转发聊天会话记录 ({{ extra.msg_ids.length }})</span> <span>转发聊天会话记录 ({{ extra.msg_ids.length }})</span>
</div> </div>
<ForwardRecord v-if="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" /> <ForwardRecord v-model:show="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" :created-at="data.created_at" :modalTitle="(extra.forward_name || title) + '的会话记录'"/>
</section> </section>
</template> </template>
@ -41,19 +47,21 @@ const onClick = () => {
.im-message-forward { .im-message-forward {
width: 250px; width: 250px;
min-height: 95px; min-height: 95px;
max-height: 150px; max-height: 190px;
border-radius: 10px; border-radius: 10px;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--im-message-border-color); border: 1px solid var(--im-message-border-color);
user-select: none; user-select: none;
.title { .title {
height: 30px; max-height: 60px;
line-height: 30px; line-height: 30px;
font-size: 15px; font-size: 15px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-weight: 400; font-weight: 400;
margin-bottom: 5px; margin-bottom: 5px;
} }

View File

@ -11,12 +11,20 @@ defineProps<{
let show = ref(false) let show = ref(false)
</script> </script>
<template> <template>
<section class="im-message-group-notice pointer" @click="show = !show"> <section
class="im-message-group-notice pointer"
@click="show = !show"
:class="{
left: data.float === 'left',
right: data.float === 'right'
}"
>
<div class="title"> <div class="title">
<n-tag :bordered="false" size="small" type="primary"> 群公告 </n-tag> <!-- <n-tag :bordered="false" size="small" type="primary"> 群公告 </n-tag>
{{ extra.title }} {{ extra.title }} -->
<text>群公告</text>
</div> </div>
<div class="content" :class="{ ellipsis: !show }"> <div class="title" :class="{ ellipsis: !show }">
{{ extra.content }} {{ extra.content }}
</div> </div>
</section> </section>
@ -30,14 +38,14 @@ let show = ref(false)
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--im-message-border-color); border: 1px solid var(--im-message-border-color);
user-select: none; user-select: none;
background-color: #fff;
.title { .title {
height: 30px; line-height: 44rpx;
line-height: 30px; font-size: 32rpx;
font-size: 14px; // overflow: hidden;
overflow: hidden; // text-overflow: ellipsis;
text-overflow: ellipsis; // white-space: nowrap;
white-space: nowrap;
font-weight: 400; font-weight: 400;
margin-bottom: 5px; margin-bottom: 5px;
position: relative; position: relative;
@ -56,5 +64,18 @@ let show = ref(false)
white-space: nowrap; white-space: nowrap;
} }
} }
&.left {
background-color: #fff;
border-radius: 0 16rpx 16rpx 16rpx;
}
&.right {
background-color: #46299d;
border-radius: 16rpx 0 16rpx 16rpx;
.title {
color: #fff;
}
}
} }
</style> </style>

View File

@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { NImage } from 'naive-ui' import { NImage, NSpin } from 'naive-ui'
import { getImageInfo } from '@/utils/functions' import { getImageInfo } from '@/utils/functions'
import { ITalkRecordExtraImage, ITalkRecord } from '@/types/chat' import { ITalkRecordExtraImage, ITalkRecord } from '@/types/chat'
defineProps<{ const props = defineProps<{
extra: ITalkRecordExtraImage extra: ITalkRecordExtraImage
data: ITalkRecord data: ITalkRecord
maxWidth?: Boolean maxWidth?: Boolean
@ -35,7 +35,13 @@ const img = (src: string, width = 200) => {
:class="{ left: data.float === 'left' }" :class="{ left: data.float === 'left' }"
:style="img(extra.url, 350)" :style="img(extra.url, 350)"
> >
<n-image :src="extra.url" /> <div class="image-container">
<n-image class="h-149px" :src="extra.url" />
<!-- 上传中的loading蒙版 -->
<div v-if="extra.is_uploading" class="loading-overlay">
<n-spin size="large" />
</div>
</div>
</section> </section>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
@ -44,11 +50,30 @@ const img = (src: string, width = 200) => {
padding: 5px; padding: 5px;
border-radius: 5px; border-radius: 5px;
background: var(--im-message-left-bg-color); background: var(--im-message-left-bg-color);
min-width: 30px; height:149px;
min-height: 30px;
&.left { &.left {
background: var(--im-message-right-bg-color); background: #F4F4FC;
}
.image-container {
position: relative;
width: 100%;
height: 100%;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
z-index: 1;
} }
:deep(.n-image img) { :deep(.n-image img) {

View File

@ -0,0 +1,95 @@
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<!-- 完全复制的textMessage组件没有用处仅兜底真有14类型时的场景后续会单独做制作分享卡片功能到时再根据分享卡片样式重做本页面 -->
<script lang="ts" setup>
import { textReplaceEmoji } from '@/utils/emojis'
import { textReplaceLink, textReplaceMention } from '@/utils/strings'
import { ITalkRecordExtraText, ITalkRecord } from '@/types/chat'
const props = defineProps<{
extra: ITalkRecordExtraText
data: ITalkRecord
maxWidth?: boolean
source?: 'panel' | 'forward' | 'history'
}>()
const float = props.data.float
let textContent = props.extra?.content || ''
textContent = textReplaceLink(textContent)
if (props.data.talk_type == 2) {
textContent = textReplaceMention(textContent, '#462AA0')
}
textContent = textReplaceEmoji(textContent)
</script>
<template>
<div
class="im-message-text"
:class="{
left: float == 'left',
right: float == 'right',
maxwidth: maxWidth,
'radius-reset': source != 'panel',
}"
>
<pre v-html="textContent" />
</div>
</template>
<style lang="less" scoped>
.im-message-text {
min-width: 40rpx;
min-height: 40rpx;
padding: 22rpx 30rpx;
color: #1a1a1a;
background: #ffffff;
border-radius: 0 16rpx 16rpx 16rpx;
&.right {
background-color: #46299d;
color: #ffffff;
border-radius: 16rpx 0 16rpx 16rpx;
}
&.maxwidth {
max-width: 486rpx;
}
&.radius-reset {
border-radius: 0;
}
pre {
white-space: pre-wrap;
overflow: hidden;
word-break: break-word;
word-wrap: break-word;
font-size: 32rpx;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
line-height: 44rpx;
:deep(.emoji) {
vertical-align: text-bottom;
margin: 0 10rpx;
width: 44rpx;
height: 44rpx;
}
:deep(a) {
color: #2196f3;
text-decoration: revert;
}
}
}
</style>

View File

@ -1,7 +1,9 @@
<script setup> <script setup>
import { formatTime } from '@/utils/datetime' import { formatTime } from '@/utils/datetime'
import { bus } from '@/utils/event-bus'
import { EditorConst } from '@/constant/event-bus'
defineProps({ const props = defineProps({
login_uid: { login_uid: {
type: Number, type: Number,
default: 0 default: 0
@ -21,18 +23,121 @@ defineProps({
datetime: { datetime: {
type: String, type: String,
default: '' default: ''
},
data: {
type: Object,
default: () => {}
},
revokeInfo: {
type: Object,
default() {
return {}
}
},
extra: {
type: String,
default: ''
} }
}) })
const onRevoke = () => {
//
if (props.data.msg_type === 1 && props.data.extra?.content) {
// 线
bus.emit(EditorConst.Edit, {
content: props.data.extra.content
})
}
}
</script> </script>
<template> <template>
<div class="im-message-revoke"> <div class="im-message-revoke">
<div class="content"> <div class="content" v-if="JSON.stringify(revokeInfo) !== '{}'">
<span v-if="login_uid == user_id"> 你撤回了一条消息 | {{ formatTime(datetime) }} </span> <span v-if="talk_type === 1 && login_uid === revokeInfo.withdraw_id">
<span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span> 你撤回了一条消息 | {{ formatTime(datetime) }}
<span v-else> </span>
"{{ nickname }}" 撤回了一条消息 | <span v-if="talk_type === 1 && login_uid !== revokeInfo.withdraw_id">
{{ revokeInfo.withdraw_name }}撤回了一条消息 | {{ formatTime(datetime) }}
</span>
<span
v-if="
talk_type === 2 &&
login_uid === revokeInfo.withdraw_id &&
login_uid === revokeInfo.retracted_id
"
>
你撤回了一条消息 |
{{ formatTime(datetime) }}
<slot></slot>
</span>
<span
v-if="
talk_type === 2 &&
login_uid === revokeInfo.withdraw_id &&
login_uid !== revokeInfo.retracted_id
"
>
你撤回了{{ revokeInfo.retracted_name }}一条消息 |
{{ formatTime(datetime) }} {{ formatTime(datetime) }}
</span> </span>
<span
v-if="
talk_type === 2 &&
login_uid !== revokeInfo.withdraw_id &&
revokeInfo.withdraw_id === revokeInfo.retracted_id
"
>
{{ revokeInfo.withdraw_name }}撤回了一条消息 |
{{ formatTime(datetime) }}
</span>
<span
v-if="
talk_type === 2 &&
login_uid !== revokeInfo.withdraw_id &&
login_uid === revokeInfo.retracted_id &&
revokeInfo.withdraw_id !== revokeInfo.retracted_id
"
>
{{ revokeInfo.withdraw_name }}撤回了你一条消息 |
{{ formatTime(datetime) }}
</span>
<span
v-if="
talk_type === 2 &&
login_uid !== revokeInfo.withdraw_id &&
login_uid !== revokeInfo.retracted_id &&
revokeInfo.withdraw_id !== revokeInfo.retracted_id
"
>
{{ revokeInfo.withdraw_name }}撤回了{{ revokeInfo.retracted_name }}一条消息 |
{{ formatTime(datetime) }}
</span>
<div style="display: inline-block;" v-if="login_uid === user_id">
<n-button @click="onRevoke" v-if="data.msg_type === 1&&data.extra?.content&&data.is_self_action" text class="text-#46299D text-11px">重新编辑</n-button>
</div>
<!-- <span v-if="login_uid == user_idA"> 你撤回B了一条消息 | {{ formatTime(datetime) }} </span>
<span v-else-if="login_uid == user_idB"> A撤回你了一条消息 | {{ formatTime(datetime) }} </span>
<span v-else> A撤回B了一条消息 | {{ formatTime(datetime) }} </span> -->
</div>
<div class="content" v-if="JSON.stringify(revokeInfo) === '{}'">
<span v-if="talk_type === 1 && login_uid === user_id">
你撤回了一条消息 | {{ formatTime(datetime) }}
</span>
<span v-if="talk_type === 1 && login_uid !== user_id">
{{ nickname }}撤回了一条消息 | {{ formatTime(datetime) }}
</span>
<span v-if="talk_type === 2 && !extra && login_uid === user_id">
你撤回了一条消息 | {{ formatTime(datetime) }}
</span>
<span v-if="talk_type === 2 && !extra && login_uid !== user_id">
{{ nickname }}撤回了一条消息 | {{ formatTime(datetime) }}
</span>
<span v-if="talk_type === 2 && extra"> {{ extra }} | {{ formatTime(datetime) }} </span>
<div style="display: inline-block;" v-if="login_uid === user_id">
<n-button @click="onRevoke" v-if="data.msg_type === 1&&data.extra?.content&&data.is_self_action" text class="text-#46299D text-11px">重新编辑</n-button>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -17,7 +17,7 @@ let textContent = props.extra?.content || ''
textContent = textReplaceLink(textContent) textContent = textReplaceLink(textContent)
if (props.data.talk_type == 2) { if (props.data.talk_type == 2) {
textContent = textReplaceMention(textContent, '#1890ff') textContent = textReplaceMention(textContent, float==='right'?'#fff':'#462AA0')
} }
textContent = textReplaceEmoji(textContent) textContent = textReplaceEmoji(textContent)
@ -43,9 +43,9 @@ textContent = textReplaceEmoji(textContent)
min-height: 30px; min-height: 30px;
padding: 3px; padding: 3px;
color: var(--im-message-left-text-color); color: var(--im-message-left-text-color);
background: var(--im-message-left-bg-color); background: #F4F4FC;
border-radius: 0px 10px 10px 10px; border-radius: 0px 10px 10px 10px;
font-size: 14px;
&.right { &.right {
background-color: var(--im-message-right-bg-color); background-color: var(--im-message-right-bg-color);
color: var(--im-message-right-text-color); color: var(--im-message-right-text-color);
@ -71,6 +71,8 @@ textContent = textReplaceEmoji(textContent)
line-height: 25px; line-height: 25px;
:deep(.emoji) { :deep(.emoji) {
width: 22px;
height: 22px;
vertical-align: text-bottom; vertical-align: text-bottom;
margin: 0 5px; margin: 0 5px;
} }

View File

@ -1,11 +1,17 @@
<script lang="ts" setup> <script lang="ts" setup>
import 'xgplayer/dist/index.min.css' import 'xgplayer/dist/index.min.css'
import { ref, nextTick } from 'vue' import { ref, nextTick, watch, computed } from 'vue'
import { NImage, NModal, NCard } from 'naive-ui' import { NImage, NModal, NCard, NProgress, NPopconfirm } from 'naive-ui'
import { Play, Close } from '@icon-park/vue-next' import { Play, Close, Pause, Right, Attention } from '@icon-park/vue-next'
import { getImageInfo } from '@/utils/functions' import { getImageInfo } from '@/utils/functions'
import {PauseOutline} from '@vicons/ionicons5'
import Player from 'xgplayer' import Player from 'xgplayer'
import { ITalkRecordExtraVideo, ITalkRecord } from '@/types/chat' import { ITalkRecordExtraVideo, ITalkRecord } from '@/types/chat'
import { useUploadsStore } from '@/store'
// @ts-ignore
const message = window.$message
const uploadsStore = useUploadsStore()
const props = defineProps<{ const props = defineProps<{
extra: ITalkRecordExtraVideo extra: ITalkRecordExtraVideo
@ -13,35 +19,75 @@ const props = defineProps<{
maxWidth?: Boolean maxWidth?: Boolean
}>() }>()
const img = (src: string, width = 200) => { // const img = (src: string, width = 200) => {
const info: any = getImageInfo(src) // const info: any = getImageInfo(src)
if (info.width == 0 || info.height == 0) { // if (info.width == 0 || info.height == 0) {
return {} // return {}
} // }
if (info.height > 300) { // if (info.height > 300) {
return { // return {
height: '300px' // height: '300px'
} // }
} // }
if (info.width < width) { // if (info.width < width) {
return { // return {
width: `${info.width}px`, // width: `${info.width}px`,
height: `${info.height}px` // height: `${info.height}px`
} // }
} // }
return { // return {
width: width + 'px', // width: width + 'px',
height: info.height / (info.width / width) + 'px' // height: info.height / (info.width / width) + 'px'
} // }
} // }
const open = ref(false) const open = ref(false)
const isPaused = ref(false)
const uploadFailed = ref(false)
//
const updatePauseStatus = () => {
if (props.extra.is_uploading && props.extra.upload_id) {
// 使
const item = uploadsStore.findItemByClientId(props.extra.upload_id)
if (item && item.is_paused !== undefined) {
isPaused.value = item.is_paused
}
}
}
//
updatePauseStatus()
// URL
const videoSrc = computed(() => {
// 使URL
return props.extra.url || ''
})
// //
// watch(() => props.extra.percentage, (newVal: number | undefined) => {
// // UI
// // (-1)
// if (newVal === -1) {
// uploadFailed.value = true
// //
// message.error('')
// } else if (newVal !== undefined && newVal > 0) {
// uploadFailed.value = false
// }
// }, { immediate: true })
async function onPlay() { async function onPlay() {
//
if (props.extra.is_uploading) {
return
}
open.value = true open.value = true
await nextTick() await nextTick()
@ -54,18 +100,86 @@ async function onPlay() {
lang: 'zh-cn' lang: 'zh-cn'
}) })
} }
//
function pauseUpload(e) {
e.stopPropagation()
if (props.extra.is_uploading && props.extra.upload_id) {
uploadsStore.pauseUpload(props.extra.upload_id)
isPaused.value = true
}
}
//
function resumeUpload(e) {
console.log('resumeUpload')
e.stopPropagation()
if (props.extra.is_uploading && props.extra.upload_id) {
uploadsStore.resumeUpload(props.extra.upload_id)
isPaused.value = false
}
}
//
// function retryUpload(e) {
// e.stopPropagation()
// if (props.extra.upload_id) {
// //
// uploadFailed.value = false
// //
// uploadsStore.resumeUpload(props.extra.upload_id)
// message.success('...')
// }
// }
</script> </script>
<template> <template>
<section <section
class="im-message-video" class="im-message-video"
:class="{ left: data.float === 'left' }" :class="{ left: data.float === 'left' }"
:style="img(extra.cover, 350)"
@click="onPlay" @click="onPlay"
> >
<n-image :src="extra.cover" preview-disabled />
<div class="btn-video"> <!-- <n-image :src="extra.cover" preview-disabled /> -->
<n-icon :component="Play" size="36" /> <video :src="videoSrc" :controls="false"></video>
<!-- 上传进度时的黑色半透明蒙层 -->
<div v-if="extra.is_uploading && !uploadFailed" class="upload-mask"></div>
<!-- 上传进度显示 -->
<div v-if="extra.is_uploading && !uploadFailed" class="upload-progress">
<n-progress
type="circle"
:percentage="Math.round(extra.percentage || 0)"
:show-indicator="false"
:stroke-width="6"
color="#fff"
rail-color="#E3E3E3"
/>
<!-- 暂停/继续按钮移到圆圈内部 -->
<div class="upload-control" @click.stop>
<n-icon
v-if="!isPaused"
class="control-btn"
:component="PauseOutline"
size="20"
@click="pauseUpload"
/>
<div v-else class="w-15px h-15px bg-#fff rounded-4px" @click="resumeUpload" >
</div>
<!-- <n-icon
v-else
class="control-btn"
:component="Right"
size="20"
@click="resumeUpload"
/> -->
</div>
</div>
<!-- 播放按钮仅在视频不是上传状态且未失败时显示 -->
<div v-if="!extra.is_uploading && !uploadFailed" class="btn-video">
<n-icon :component="Play" size="40" />
</div> </div>
<n-modal v-model:show="open"> <n-modal v-model:show="open">
@ -92,23 +206,25 @@ async function onPlay() {
min-height: 30px; min-height: 30px;
display: inline-flex; display: inline-flex;
position: relative; position: relative;
height:149px;
width: 225px;
&.left { &.left {
background: var(--im-message-right-bg-color); background: var(--im-message-right-bg-color);
} }
:deep(.n-image img) { video {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 5px; border-radius: 5px;
object-fit: cover;
background-color: #333; /* 添加背景色,避免默认显示为灰色 */
} }
.btn-video { .btn-video {
width: 30px; left: 50%;
height: 20px; top: 50%;
transform: translate(-50%, -50%);
position: absolute; position: absolute;
left: calc(50% - 15px);
top: calc(50% - 10px);
cursor: pointer; cursor: pointer;
color: #ffffff; color: #ffffff;
} }
@ -134,4 +250,66 @@ async function onPlay() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.upload-mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3); /* 降低不透明度从0.45改为0.3,让视频封面能够显示 */
z-index: 1;
border-radius: 5px;
}
.upload-progress {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
z-index: 2;
.upload-control {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
.control-btn {
color: white;
z-index: 2;
}
}
}
/* 上传失败样式 */
.upload-failed {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 2;
.failed-icon {
width: 30px;
height: 30px;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.9);
}
}
}
</style> </style>

View File

@ -121,7 +121,7 @@ onMounted(() => {
:height="5" :height="5"
:show-indicator="false" :show-indicator="false"
:percentage="parseInt(option.progress)" :percentage="parseInt(option.progress)"
color="#1890ff" color="#462AA0"
/> />
</p> </p>
</div> </div>

View File

@ -0,0 +1,25 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<template v-for="(user, index) in extra.members" :key="index">
<a>{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
<span>已成为管理员</span>
</div>
</div>
</template>

View File

@ -13,7 +13,7 @@ defineProps({
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)"> <a >
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>

View File

@ -13,14 +13,15 @@ const { showUserInfoModal } = useInject()
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
<a >
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>
<span>创建了群聊并邀请了</span> <span>创建了群聊并邀请了</span>
<template v-for="(user, index) in extra.members" :key="index"> <template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a> <a >{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em> <em v-show="index < extra.members.length - 1"></em>
</template> </template>
</div> </div>

View File

@ -0,0 +1,19 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
const { showUserInfoModal } = useInject()
defineProps({
extra: Object,
data: Object
})
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<span>{{ extra.content }}</span>
</div>
</div>
</template>

View File

@ -0,0 +1,25 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
const { showUserInfoModal } = useInject()
defineProps({
extra: Object,
data: Object
})
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a>
<!-- {{ data.nickname }} -->
管理员
</a>
<!-- <span>修改群名为</span>
<span>"{{ extra.group_name }}"</span> -->
<span>修改了群信息</span>
</div>
</div>
</template>

View File

@ -13,14 +13,14 @@ const { showUserInfoModal } = useInject()
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)"> <a >
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>
<span>邀请了</span> <span>邀请了</span>
<template v-for="(user, index) in extra.members" :key="index"> <template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a> <a>{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em> <em v-show="index < extra.members.length - 1"></em>
</template> </template>

View File

@ -13,14 +13,14 @@ const { showUserInfoModal } = useInject()
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)"> <a >
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>
<span>解除了</span> <span>解除了</span>
<template v-for="(user, index) in extra.members" :key="index"> <template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a> <a >{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em> <em v-show="index < extra.members.length - 1"></em>
</template> </template>

View File

@ -13,18 +13,18 @@ const { showUserInfoModal } = useInject()
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)"> <a>
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>
<span></span> <span></span>
<template v-for="(user, index) in extra.members" :key="index"> <template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a> <a>{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em> <em v-show="index < extra.members.length - 1"></em>
</template> </template>
<span>出群聊</span> <span>出群聊</span>
</div> </div>
</div> </div>
</template> </template>

View File

@ -13,14 +13,14 @@ const { showUserInfoModal } = useInject()
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)"> <a>
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>
<span>设置了</span> <span>设置了</span>
<template v-for="(user, index) in extra.members" :key="index"> <template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a> <a>{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em> <em v-show="index < extra.members.length - 1"></em>
</template> </template>

View File

@ -13,7 +13,7 @@ const { showUserInfoModal } = useInject()
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)"> <a >
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>

View File

@ -0,0 +1,23 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
const { showUserInfoModal } = useInject()
defineProps({
extra: Object,
data: Object,
})
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<template v-for="(user, index) in extra?.members" :key="index">
<a >{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
<span>已离开此群</span>
</div>
</div>
</template>

View File

@ -13,7 +13,7 @@ const { showUserInfoModal } = useInject()
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)"> <a >
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>

View File

@ -13,9 +13,9 @@ const { showUserInfoModal } = useInject()
<template> <template>
<div class="im-message-sys-text"> <div class="im-message-sys-text">
<div class="sys-text"> <div class="sys-text">
<a @click="showUserInfoModal(extra.old_owner_id)">{{ extra.old_owner_name }}</a> <a >{{ extra.old_owner_name }}</a>
<span>将群主转让给</span> <span>将群主转让给</span>
<a @click="showUserInfoModal(extra.new_owner_id)">{{ extra.new_owner_name }}</a> <a >{{ extra.new_owner_name }}</a>
</div> </div>
</div> </div>
</template> </template>

View File

@ -10,7 +10,6 @@
padding: 0 8px; padding: 0 8px;
word-wrap: break-word; word-wrap: break-word;
color: #979191; color: #979191;
user-select: none;
font-weight: 300; font-weight: 300;
display: inline-block; display: inline-block;
border-radius: 3px; border-radius: 3px;
@ -23,13 +22,11 @@
a { a {
color: #939596; color: #939596;
cursor: pointer;
font-size: 12px; font-size: 12px;
font-weight: 400; font-weight: 400;
&:hover {
color: #1890ff;
}
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More