Compare commits

...

92 Commits

Author SHA1 Message Date
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
152 changed files with 23993 additions and 3713 deletions

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

View File

@ -5,4 +5,4 @@
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"trailingComma": "none" "trailingComma": "none"
} }

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

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

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

9309
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,41 @@
{ {
"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/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",
"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 +45,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 +89,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)
@ -86,3 +89,13 @@ 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)
}

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', data)
}
// ES搜索用户数据
export const ServeQueryUser = (data) => {
return post('/api/v1/elasticsearch/query-user', 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  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
})
})
}

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,40 +326,54 @@ 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() 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
} }
//
let src = await onUploadImage(file) let src = await onUploadImage(file)
if (src) { if (src) {
quill.insertEmbed(index, 'image', src) quill.insertEmbed(index, 'image', src)
@ -304,29 +385,41 @@ async function onUploadFile(e: any) {
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 +430,16 @@ 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) { if (data.items.length === 0) {
return return //
} }
switch (data.msgType) { switch (data.msgType) {
@ -351,60 +448,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 +524,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 +572,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 +639,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 +653,7 @@ useEventBus([
</div> </div>
</header> </header>
<!-- 编辑器主体区域 -->
<main class="el-main height100"> <main class="el-main height100">
<QuillEditor <QuillEditor
ref="editor" ref="editor"
@ -514,11 +666,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 +690,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 +713,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 +731,7 @@ useEventBus([
&:hover { &:hover {
.tip-title { .tip-title {
display: block; display: block; /* 悬停时显示提示文字 */
} }
} }
} }
@ -585,6 +739,7 @@ useEventBus([
} }
} }
/* 暗色模式样式调整 */
html[theme-mode='dark'] { html[theme-mode='dark'] {
.editor { .editor {
--tip-bg-color: #48484d; --tip-bg-color: #48484d;
@ -593,13 +748,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 +769,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 +777,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 +786,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 +794,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 +808,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 +856,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>
@ -185,17 +185,18 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.option { .option{
height: 32px; margin: 7px;
width: 32px; :deep(.emoji){
margin: 2px; height: 22px;
font-size: 24px; 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,383 @@
<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"
/>
<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 } 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
: ChatMsgTypeMapping[props.searchItem?.msg_type]
break
default:
result_detail = ''
}
return result_detail
})
</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;
}
}
.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: #f8f8f8;
.info-detail-searchRecordDetail {
.searchRecordDetail-fastLocal {
display: block;
}
}
}
</style>

View File

@ -0,0 +1,783 @@
<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
}
}
if (total < props.searchResultPageSize) {
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: #f8f8f8;
}
}
}
}
}
</style>

View File

@ -5,22 +5,21 @@ 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'
const props = defineProps({ const props = defineProps({
msgId: { msgId: {
type: String, type: String,
required: true 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('会话记录')
const onMaskClick = () => { const onMaskClick = () => {
emit('close') isShow.value=false
} }
const onLoadData = () => { const onLoadData = () => {
@ -30,18 +29,92 @@ const onLoadData = () => {
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 +153,7 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</n-modal> </n-modal> -->
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
@ -94,10 +167,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,12 @@ 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-#f5f5f5 rounded-10px px-11px">
<div class="im-message-audio h-44px">
<audio <audio
ref="audioRef" ref="audioRef"
preload="auto" preload="auto"
@ -98,20 +100,27 @@ const formatTime = (value: number = 0) => {
</div> </div>
<div class="time">{{ durationDesc }}</div> <div class="time">{{ durationDesc }}</div>
</div> </div>
<transition name="expand">
<div class="text-container py-12px border-t-2px border-t-solid border-t-#E0E0E4" 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>
</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;
@ -132,6 +141,7 @@ const formatTime = (value: number = 0) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.3s ease;
} }
} }
@ -230,6 +240,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 +252,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,260 @@
<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.name)
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
})
//
function getFileExtension(filename) {
const parts = filename.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){
window.open(
`${window.location.origin}/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'
);
}
}
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" @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> </div>
<div class="footer"> <!-- 文件大小信息 -->
<a @click="download(data.msg_id)">下载</a> <div class="flex justify-between items-center grow-1">
<a>在线预览</a> <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>
</section> </div>
</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;
cursor: pointer;
}
.main { .file-header {
height: 45px; display: flex;
display: flex; padding: 14px 5px 14px 0;
flex-direction: row; justify-content: space-between;
margin-top: 5px; width: 100%;
border-bottom: 1px solid #EEEEEE;
}
.ext { .file-name {
display: flex; height: 50px;
justify-content: center; color: #1A1A1A;
align-items: center; font-size: 14px;
width: 45px; word-break: break-word;
height: 45px; overflow: hidden;
color: #ffffff; text-overflow: ellipsis;
background: #49a4ff; display: -webkit-box;
border-radius: 5px; -webkit-line-clamp: 2;
font-size: 12px; -webkit-box-orient: vertical;
} }
.file-box { .file-icon-container {
flex: 1 1; height: 48px;
height: 45px; position: relative;
margin-left: 10px; }
overflow: hidden;
.info { .file-icon {
display: flex; width: 48px;
justify-content: space-between; height: 48px;
align-items: center; }
overflow: hidden;
height: 24px;
font-size: 14px;
.name { .progress-overlay {
flex: 1 auto; background-color: #fff;
white-space: nowrap; position: absolute;
overflow: hidden; top: 6px;
text-overflow: ellipsis; left: 11px;
} width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
}
.size { .file-size {
font-size: 12px; color: #747474;
color: #cac6c6; font-size: 12px;
flex-shrink: 0; }
}
}
.notice { .circle-progress-container {
height: 25px; width: 20px;
line-height: 25px; height: 20px;
font-size: 12px; position: relative;
color: #929191; cursor: pointer;
white-space: nowrap; }
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.footer { .circle-progress {
height: 30px; transform: rotate(-90deg);
line-height: 37px; transform-origin: center;
text-align: right; }
font-size: 12px;
border-top: 1px solid var(--border-color);
margin-top: 10px;
a { .progress-circle {
margin: 0 3px; transition: stroke-dashoffset 0.3s ease;
user-select: none; }
cursor: pointer;
color: var(--im-text-color);
&:hover { .pause-icon, .play-icon {
color: royalblue; transform-origin: center;
} }
}
} .pause-icon {
transform: rotate(90deg);
} }
</style> </style>

View File

@ -33,7 +33,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" />
</section> </section>
</template> </template>

View File

@ -35,7 +35,7 @@ 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" /> <n-image class="h-149px" :src="extra.url" />
</section> </section>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
@ -44,9 +44,7 @@ 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: var(--im-message-right-bg-color);
} }

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,13 +23,30 @@ defineProps({
datetime: { datetime: {
type: String, type: String,
default: '' default: ''
},
data: {
type: Object,
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">
<span v-if="login_uid == user_id"> 你撤回了一条消息 | {{ formatTime(datetime) }} </span> <div v-if="login_uid === user_id">
<span> 你撤回了一条消息 | {{ formatTime(datetime) }} </span>
<n-button @click="onRevoke" v-if="data.msg_type === 1&&data.extra?.content" text class="text-#46299D text-11px">重新编辑</n-button>
</div>
<span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span> <span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span>
<span v-else> <span v-else>
"{{ nickname }}" 撤回了一条消息 | "{{ nickname }}" 撤回了一条消息 |

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, '#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 } 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,70 @@ 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) {
// return {
// width: `${info.width}px`,
// height: `${info.height}px`
// }
// }
// return {
// width: width + 'px',
// height: info.height / (info.width / width) + 'px'
// }
// }
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
} }
} }
if (info.width < width) {
return {
width: `${info.width}px`,
height: `${info.height}px`
}
}
return {
width: width + 'px',
height: info.height / (info.width / width) + 'px'
}
} }
const open = ref(false) //
updatePauseStatus()
// //
// 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 +95,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 />
<!-- <n-image :src="extra.cover" preview-disabled /> -->
<video :src="props.extra.url" :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 class="btn-video"> </div>
<n-icon :component="Play" size="36" /> <!-- <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 +201,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 +245,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.45);
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 @click="showUserInfoModal(user.erp_user_id,user.user_id)">{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
<span>已成为管理员</span>
</div>
</div>
</template>

View File

@ -13,6 +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 @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }} {{ extra.owner_name }}
</a> </a>

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 @click="showUserInfoModal(data.user_id)">
<!-- {{ data.nickname }} -->
管理员
</a>
<!-- <span>修改群名为</span>
<span>"{{ extra.group_name }}"</span> -->
<span>修改了群信息</span>
</div>
</div>
</template>

View File

@ -24,7 +24,7 @@ const { showUserInfoModal } = useInject()
<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

@ -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 @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
<span>已离开此群</span>
</div>
</div>
</template>

View File

@ -28,7 +28,7 @@
font-weight: 400; font-weight: 400;
&:hover { &:hover {
color: #1890ff; color: #462AA0;
} }
} }
} }

View File

@ -1,12 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { NModal, NInput, NScrollbar, NCheckbox, NTabs, NTab } from 'naive-ui' import { ServeGetTalkList } from '@/api/chat.js'
import { Search, Delete } from '@icon-park/vue-next'
import { ServeGetContacts } from '@/api/contact'
import { ServeGetGroups } from '@/api/group' import { ServeGetGroups } from '@/api/group'
import XNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
const emit = defineEmits(['close', 'on-submit']) const emit = defineEmits(['close', 'on-submit'])
import { CloseCircle } from '@vicons/ionicons5'
interface Item { interface Item {
id: number id: number
type: number type: number
@ -17,16 +15,18 @@ interface Item {
keyword: string keyword: string
} }
const tabsIndex = ref<number>(1) const isShowBox = defineModel('show')
const isShowBox = ref(true)
const loading = ref(true) const loading = ref(true)
const items = ref<Item[]>([]) const items = ref<Item[]>([])
const keywords = ref('') const keywords = ref('')
const loadGroupStatus = ref(false) const loadGroupStatus = ref(false)
defineProps<{
forwardMode: number
}>()
//
const searchFilter = computed(() => { const searchFilter = computed(() => {
return items.value.filter((item: Item) => { return items.value.filter((item: Item) => {
return tabsIndex.value == item.type && item.keyword.match(keywords.value) != null return item.name.toLowerCase().includes(keywords.value.toLowerCase())
}) })
}) })
@ -40,23 +40,19 @@ const isCanSubmit = computed(() => {
const onLoad = () => { const onLoad = () => {
onLoadContact() onLoadContact()
// onLoadGroup()
} }
const onLoadContact = () => { const onLoadContact = () => {
loading.value = true loading.value = true
ServeGetContacts() ServeGetTalkList()
.then((res) => { .then((res) => {
if (res.code == 200) { if (res.code == 200) {
let list = res.data.items || [] let list = res.data.items || []
items.value = list.map((item: any) => { items.value = list.map((item: any) => {
return { return {
id: item.id, ...item,
avatar: item.avatar,
type: 1,
name: item.remark || item.nickname,
keyword: item.remark + item.nickname,
remark: item.remark,
checked: false checked: false
} }
}) })
@ -67,40 +63,48 @@ const onLoadContact = () => {
}) })
} }
const onLoadGroup = async () => { // const onLoadGroup = async () => {
if (loadGroupStatus.value) { // if (loadGroupStatus.value) {
return // return
} // }
loading.value = true // loading.value = true
let { code, data } = await ServeGetGroups() // let { code, data } = await ServeGetGroups()
if (code != 200) { // if (code != 200) {
return // loading.value = false
} // return
// }
let list = data.items.map((item: any) => { // let list = data.items.map((item: any) => {
return { // return {
id: item.id, // id: item.id,
avatar: item.avatar, // avatar: item.avatar,
type: 2, // type: 2,
name: item.group_name, // name: item.group_name,
keyword: item.group_name, // keyword: item.group_name,
remark: '', // remark: '',
checked: false // checked: false
} // }
}) // })
items.value.push(...list) // items.value.push(...list)
loading.value = false // loading.value = false
loadGroupStatus.value = true // loadGroupStatus.value = true
} // }
const onMaskClick = () => { const onMaskClick = () => {
emit('close') emit('close')
} }
const onTriggerContact = (item: any) => { const onTriggerContact = (item: any) => {
//
if (selectType.value === 1) {
items.value.forEach(contact => {
contact.checked = false
})
}
let data = items.value.find((val: any) => val.id === item.id) let data = items.value.find((val: any) => val.id === item.id)
if (data) { if (data) {
@ -108,230 +112,152 @@ const onTriggerContact = (item: any) => {
} }
} }
const onRemoveContact = (item: any) => {
let data = items.value.find((val: any) => val.id === item.id)
if (data) {
data.checked = false
}
}
const onCancel = () => {
isShowBox.value = false
}
const onSubmit = () => { const onSubmit = () => {
let data = checkedFilter.value.map((item: any) => { let data = checkedFilter.value.map((item: any) => {
return { return {
id: item.id, receiver_id: item.receiver_id,
type: item.type talk_type: item.talk_type
} }
}) })
console.log('data', data);
console.log('checkedFilter.value', checkedFilter.value);
emit('on-submit', data) emit('on-submit', data)
} }
const onTabs = (value: number) => { // 1 2
tabsIndex.value = value const selectType = ref(1)
if (value == 2) { const changeSelectType = () => {
onLoadGroup() selectType.value = selectType.value == 1 ? 2 : 1
}
//
items.value.forEach(item => {
item.checked = false
})
} }
onLoad() watch(()=>{
return isShowBox.value
},(newVal)=>{
if(newVal){
onLoad()
}
})
</script> </script>
<template> <template>
<n-modal <x-n-modal v-model:show="isShowBox" :title="forwardMode === 2 ? '合并转发' : '逐条转发'" style="width: 997px; height: 740px;background-color: #F9F9FD"
v-model:show="isShowBox" :on-after-leave="onMaskClick" content-style="display: flex; justify-content: center; align-items: center;">
preset="card" <div class="w-927px h-627px bg-#fff rounded-3px px-35px py-20px">
title="选择联系人" <div class="flex items-center justify-between mb-28px">
class="modal-radius" <div class="text-#333639">搜索</div>
style="max-width: 650px; height: 550px" <div class="w-779px h-34px">
:on-after-leave="onMaskClick" <n-input v-model:value="keywords" type="text" clearable placeholder="请输入">
:segmented="{
content: true, </n-input>
footer: true
}"
:content-style="{
padding: 0
}"
>
<section class="el-container launch-box">
<aside class="el-aside bdr-r" style="width: 240px">
<section class="el-container is-vertical height100">
<header class="el-header tabs">
<n-tabs type="line" justify-content="space-around" @update:value="onTabs">
<n-tab name="1"> 好友 </n-tab>
<n-tab name="2"> 群聊 </n-tab>
<!-- <n-tab name="企业"> 企业 </n-tab> -->
</n-tabs>
</header>
<header class="el-header sub-header">
<n-input placeholder="搜索" v-model:value="keywords" clearable size="small">
<template #prefix>
<n-icon :component="Search" />
</template>
</n-input>
</header>
<main class="el-main" v-loading="loading" loading-text="加载中...">
<n-scrollbar>
<div class="friend-items">
<div
class="friend-item pointer"
v-for="item in searchFilter"
:key="item.id"
@click="onTriggerContact(item)"
>
<div class="avatar">
<im-avatar
class="pointer"
:src="item.avatar"
:size="25"
:username="item.remark || item.name"
/>
</div>
<div class="content">
<span class="text-ellipsis">{{ item.remark || item.name }}</span>
</div>
<div class="checkbox">
<n-checkbox size="small" :checked="item.checked" />
</div>
</div>
</div>
</n-scrollbar>
</main>
</section>
</aside>
<main class="el-main">
<section class="el-container is-vertical height100">
<main class="el-main o-hidden">
<n-scrollbar class="friend-items">
<div class="friend-items">
<div v-show="!checkedFilter.length" style="padding-top: 100px">
<n-empty size="200" description="暂无数据">
<template #icon>
<img src="@/assets/image/no-data.svg" alt="" />
</template>
</n-empty>
</div>
<div
class="friend-item pointer"
v-for="item in checkedFilter"
:key="item.id"
@click="onTriggerContact(item)"
>
<div class="avatar">
<im-avatar
class="pointer"
:src="item.avatar"
:size="25"
:username="item.remark || item.name"
/>
</div>
<div class="content">
<span class="text-ellipsis">
{{ item.remark || item.name }}
</span>
<span v-if="item.type == 2" class="badge group"></span>
</div>
<div class="checkbox">
<n-icon :size="16" :component="Delete" />
</div>
</div>
</div>
</n-scrollbar>
</main>
</section>
</main>
</section>
<template #footer>
<div class="footer">
<div>
<span>已选择({{ checkedFilter.length }})</span>
</div>
<div>
<n-button type="tertiary" @click="isShowBox = false"> 取消 </n-button>
<n-button type="primary" class="mt-l15" @click="onSubmit" :disabled="isCanSubmit">
确定
</n-button>
</div> </div>
</div> </div>
</template> <div class="flex justify-between">
</n-modal> <div class="w-260px h-517px rounded-4px border-1px border-solid border-#E5E5E5 px-12px">
<div class="border-b-2px border-b-solid border-b-#FBFBFB h-35px flex items-center justify-end">
<n-button text color="#46299D" class="text-14px" @click="changeSelectType">
{{ selectType === 1 ? '多选' : '单选' }}
</n-button>
</div>
<div>
<n-virtual-list v-if="!loading" style="max-height: 470px" :item-size="65" :items="searchFilter">
<template #default="{ item }">
<div class="flex items-center border-b-2px border-b-solid h-65px border-b-#FBFBFB"
@click="onTriggerContact(item)">
<div class="mr-22px">
<n-radio v-if="selectType === 1" :checked="item.checked" />
<n-checkbox v-else :checked="item.checked" />
</div>
<div class="mr-10px">
<avatarModule class="mr-10px" showGroupType :mode="item.talk_type"
:avatar="item.avatar"
:groupType="item.group_type"
:customStyle="{width:'42px',height:'42px'}"></avatarModule>
<!-- <n-image class="w-42px h-42px rounded-full" :src="item.avatar" /> -->
</div>
<div class="flex items-center">
<span class="text-ellipsis">{{ item.name }}</span>
<span v-if="item.type == 2" class="badge group ml-2"></span>
</div>
</div>
</template>
</n-virtual-list>
<div v-else class="flex-center h-470px">
<span>加载中...</span>
</div>
</div>
</div>
<div class="w-578px h-517px rounded-4px border-1px border-solid border-#E5E5E5 px-12px">
<div class="border-b-2px border-b-solid border-b-#FBFBFB h-35px flex items-center text-14px text-#000">
发送给
</div>
<div class="h-350px border-b-2px border-b-solid border-b-#FBFBFB">
<div v-if="checkedFilter.length > 0">
<n-virtual-list style="max-height: 350px" :item-size="65" :items="checkedFilter">
<template #default="{ item }">
<div class="flex items-center border-b-2px border-b-solid h-65px border-b-#FBFBFB pr-20px">
<div class="mr-10px">
<avatarModule class="mr-10px" showGroupType :mode="item.talk_type"
:avatar="item.avatar"
:groupType="item.group_type"
:customStyle="{width:'42px',height:'42px'}"></avatarModule>
</div>
<div class="flex items-center">
<span class="text-ellipsis">{{ item.name }}</span>
<span v-if="item.type == 2" class="badge group ml-2"></span>
</div>
<n-button class="ml-auto" text color="#C7C7C9" @click="onRemoveContact(item)">
<n-icon :component="CloseCircle" size="18" />
</n-button>
</div>
</template>
</n-virtual-list>
</div>
<div v-else class="flex-center h-350px">
<n-empty size="medium" description="暂无选择联系人">
<template #icon>
<img src="@/assets/image/no-data.svg" alt="" />
</template>
</n-empty>
</div>
</div>
<div class="flex flex-col items-center justify-center h-120px">
<div class="text-14px text-#999999 mb-23px">
<span>[{{ forwardMode === 2 ? '合并转发' : '逐条转发' }}]</span>
<span v-if="checkedFilter.length > 0">
{{
checkedFilter.length > 2
? checkedFilter.slice(0, 2).map(item => item.name).join('、') + ' 等'
: checkedFilter.map(item => item.name).join('、')
}}会话记录
</span>
<span v-else>请选择联系人</span>
</div>
<div class="flex justify-center items-center">
<n-button color="#C7C7C9" class="w-250px h-34px text-14px text-#fff mr-10px" @click="onCancel">取消</n-button>
<n-button color="#46299D" class="w-250px h-34px text-14px text-#fff"
@click="onSubmit" :disabled="isCanSubmit">发送</n-button>
</div>
</div>
</div>
</div>
</div>
</x-n-modal>
</template> </template>
<style lang="less" scoped>
:deep(.n-divider__title) {
font-weight: unset;
}
.launch-box {
height: 410px;
width: 100%;
overflow: hidden;
.sub-header {
height: 50px;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.friend-items {
height: 100%;
overflow-y: auto;
padding: 0 15px;
.friend-item {
height: 40px;
box-sizing: border-box;
display: flex;
flex-direction: row;
margin: 5px 0;
> div {
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
width: 30px;
justify-content: flex-start;
}
.content {
flex: 1 auto;
padding-left: 8px;
overflow: hidden;
font-size: 14px;
font-weight: 400;
justify-content: flex-start;
&:hover {
color: #409eff;
}
}
.checkbox {
flex-shrink: 0;
width: 30px;
justify-content: flex-end;
}
}
}
}
.badge {
&.group {
color: #3370ff !important;
background-color: #e1eaff !important;
}
margin: 0 3px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@ -1,18 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, reactive } from 'vue' import { ref, computed, reactive } from 'vue'
import { NIcon, NModal, NButton, NInput, NDropdown, NPopover } from 'naive-ui'
import { CloseOne, Male, Female, SendOne } from '@icon-park/vue-next' import { CloseOne, Male, Female, SendOne } from '@icon-park/vue-next'
import { ServeSearchUser } from '@/api/contact' import { ServeSearchUser } from '@/api/contact'
import { ServeCreateContact } from '@/api/contact' import { ServeCreateContact } from '@/api/contact'
import { ServeContactGroupList, ServeContactMoveGroup, ServeEditContactRemark } from '@/api/contact' import { ServeContactGroupList, ServeContactMoveGroup, ServeEditContactRemark } from '@/api/contact'
import { useTalkStore } from '@/store' import { useTalkStore } from '@/store'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import xNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
import { NSkeleton } from 'naive-ui'
const router = useRouter() const router = useRouter()
const talkStore = useTalkStore() const talkStore = useTalkStore()
const emit = defineEmits(['update:show', 'update:uid', 'updateRemark']) const emit = defineEmits(['update:show', 'update:uid', 'updateRemark'])
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
@ -21,12 +19,16 @@ const props = defineProps({
uid: { uid: {
type: Number, type: Number,
default: 0 default: 0
},
euid: {
type: Number,
default: 0
} }
}) })
const loading = ref(true) const loading = ref(true)
const isOpenFrom = ref(false) const isOpenFrom = ref(false)
const state: any = reactive({ const userInfo: any = ref({
id: 0, id: 0,
avatar: '', avatar: '',
gender: 0, gender: 0,
@ -43,26 +45,26 @@ const editCardPopover: any = ref(false)
const modelRemark = ref('') const modelRemark = ref('')
const options = ref<any>([]) const options = ref<any>([])
const groupName = computed(() => { // const groupName = computed(() => {
const item = options.value.find((item: any) => { // const item = options.value.find((item: any) => {
return item.key == state.group_id // return item.key == state.group_id
}) // })
if (item) { // if (item) {
return item.label // return item.label
} // }
return '未设置分组' // return ''
}) // })
const onLoadData = () => { const onLoadData = () => {
ServeSearchUser({ ServeSearchUser({
user_id: props.uid erp_user_id: props.euid
}).then(({ code, data }) => { }).then(({ code, data }) => {
if (code == 200) { if (code == 200) {
Object.assign(state, data) userInfo.value = data
modelRemark.value = state.remark // modelRemark.value = state.remark
loading.value = false loading.value = false
} else { } else {
@ -70,15 +72,15 @@ const onLoadData = () => {
} }
}) })
ServeContactGroupList().then((res) => { // ServeContactGroupList().then((res) => {
if (res.code == 200) { // if (res.code == 200) {
let items = res.data.items || [] // let items = res.data.items || []
options.value = [] // options.value = []
for (const iter of items) { // for (const iter of items) {
options.value.push({ label: iter.name, key: iter.id }) // options.value.push({ label: iter.name, key: iter.id })
} // }
} // }
}) // })
} }
const onToTalk = () => { const onToTalk = () => {
@ -86,247 +88,190 @@ const onToTalk = () => {
emit('update:show', false) emit('update:show', false)
} }
const onJoinContact = () => { // const onJoinContact = () => {
if (!state.text.length) { // if (!state.text.length) {
return window['$message'].info('备注信息不能为空') // return window['$message'].info('')
} // }
ServeCreateContact({ // ServeCreateContact({
friend_id: props.uid, // friend_id: props.uid,
remark: state.text // remark: state.text
}).then((res) => { // }).then((res) => {
if (res.code == 200) { // if (res.code == 200) {
isOpenFrom.value = false // isOpenFrom.value = false
window['$message'].success('申请发送成功') // window['$message'].success('')
} else { // } else {
window['$message'].error(res.message) // window['$message'].error(res.message)
} // }
}) // })
} // }
const onChangeRemark = () => { // const onChangeRemark = () => {
ServeEditContactRemark({ // ServeEditContactRemark({
friend_id: props.uid, // friend_id: props.uid,
remark: modelRemark.value // remark: modelRemark.value
}).then(({ code, message }) => { // }).then(({ code, message }) => {
if (code == 200) { // if (code == 200) {
editCardPopover.value.setShow(false) // editCardPopover.value.setShow(false)
window['$message'].success('备注成功') // window['$message'].success('')
state.remark = modelRemark.value // state.remark = modelRemark.value
emit('updateRemark', { // emit('updateRemark', {
user_id: props.uid, // user_id: props.uid,
remark: modelRemark.value // remark: modelRemark.value
}) // })
} else { // } else {
window['$message'].error(message) // window['$message'].error(message)
} // }
}) // })
} // }
const handleSelectGroup = (value) => { // const handleSelectGroup = (value) => {
ServeContactMoveGroup({ // ServeContactMoveGroup({
user_id: props.uid, // user_id: props.uid,
group_id: value // group_id: value
}).then(({ code, message }) => { // }).then(({ code, message }) => {
if (code == 200) { // if (code == 200) {
state.group_id = value // state.group_id = value
window['$message'].success('分组修改成功') // window['$message'].success('')
} else { // } else {
window['$message'].error(message) // window['$message'].error(message)
} // }
}) // })
} // }
const reset = () => { // const reset = () => {
loading.value = true // loading.value = true
Object.assign(state, { // Object.assign(state, {
id: 0, // id: 0,
avatar: '', // avatar: '',
gender: 0, // gender: 0,
mobile: '', // mobile: '',
motto: '', // motto: '',
nickname: '', // nickname: '',
remark: '', // remark: '',
email: '', // email: '',
status: 1, // status: 1,
text: '' // text: ''
}) // })
isOpenFrom.value = false // isOpenFrom.value = false
} // }
const onUpdate = (value) => { // const onUpdate = (value) => {
if (!value) { // if (!value) {
setTimeout(reset, 100) // setTimeout(reset, 100)
} // }
emit('update:show', value) // emit('update:show', value)
} // }
const onAfterEnter = () => { const onAfterEnter = () => {
onLoadData() onLoadData()
} }
const onAfterLeave = () => {
// loading.value = true
userInfo.value = {
id: 0,
avatar: '',
gender: 0,
mobile: '',
motto: '',
nickname: '',
remark: '',
email: '',
status: 1,
text: ''
}
}
</script> </script>
<template> <template>
<n-modal :show="show" :on-update:show="onUpdate" :on-after-enter="onAfterEnter"> <x-n-modal content-style="padding:0;" :closable="false" class="w-311px min-h-445px" style="border-radius: 10px;overflow:hidden;" :show="show" :on-after-leave="onAfterLeave" :on-after-enter="onAfterEnter">
<div class="section" v-loading="loading"> <div class="section relative px-7px pt-82px pb-20px">
<section class="el-container container is-vertical"> <div class="absolute top-9px right-7px pointer z-10" @click="emit('update:show', false)">
<header class="el-header header"> <img class="w-20px h-20px" src="@/assets/image/close.png" alt="">
<im-avatar </div>
class="avatar"
:size="100" <template v-if="loading">
:src="state.avatar" <div class="flex py-10px bg-#fff px-16px rounded-4px items-center mb-10px">
:username="state.remark || state.nickname" <div class="w-59px h-59px rounded-8px mr-12px">
:font-size="30" <n-skeleton height="59px" width="59px" />
/>
<div class="gender" v-show="state.gender > 0">
<n-icon v-if="state.gender == 1" :component="Male" color="#508afe" />
<n-icon v-if="state.gender == 2" :component="Female" color="#ff5722" />
</div> </div>
<div class="w-full">
<div class="close" @click="onUpdate(false)"> <n-skeleton text style="width: 80%; margin-bottom: 5px;" />
<close-one theme="outline" size="22" fill="#fff" :strokeWidth="2" /> <n-skeleton text style="width: 60%;" />
</div> </div>
</div>
<div class="nickname text-ellipsis"> <div class="bg-#fff rounded-4px mb-20px">
{{ state.remark || state.nickname || '未设置昵称' }} <div class="flex px-15px py-9px" v-for="i in 6" :key="i">
<n-skeleton text style="width: 30%; margin-right: 10px;" />
<n-skeleton text style="width: 60%;" />
</div> </div>
</header> </div>
<div>
<n-skeleton text style="width: 100%; height: 42px; border-radius: 4px;" />
</div>
</template>
<template v-else>
<div class="flex py-10px bg-#fff px-16px rounded-4px items-center mb-10px">
<div class="w-59px h-59px rounded-8px mr-12px overflow-hidden">
<n-image width="59" :src="userInfo.avatar" >
<main class="el-main main me-scrollbar me-scrollbar-thumb"> </n-image>
<div class="motto">
{{ state.motto || '编辑个签,展示我的独特态度。' }}
</div> </div>
<div>
<div class="infos"> <div class="text-#000 text-16px mb-5px">{{ userInfo.nickname }}</div>
<div class="info-item"> <div class="text-#ACACAC text-12px">工号{{ userInfo.job_num }}</div>
<span class="name">工号 :</span>
<span class="text">{{ state.job_num}}</span>
</div>
<div class="info-item">
<span class="name">手机 :</span>
<span class="text">{{ state.mobile }}</span>
</div>
<div class="info-item">
<span class="name">昵称 :</span>
<span class="text text-ellipsis">{{ state.nickname || '-' }} </span>
</div>
<div class="info-item">
<span class="name">性别 :</span>
<span class="text">{{
state.gender == 1 ? '男' : state.gender == 2 ? '女' : '未知'
}}</span>
</div>
<div class="info-item" v-if="state.friend_status == 2">
<span class="name">备注 :</span>
<n-popover trigger="click" placement="top-start" ref="editCardPopover">
<template #trigger>
<span class="text edit pointer text-ellipsis">
{{ state.remark || '未设置' }}&nbsp;&nbsp;
</span>
</template>
<template #header> 设置备注 </template>
<div style="display: flex">
<n-input
type="text"
placeholder="请填写备注"
:autofocus="true"
maxlength="10"
v-model:value="modelRemark"
@keydown.enter="onChangeRemark"
/>
<n-button
type="primary"
text-color="#ffffff"
class="mt-l5"
@click="onChangeRemark"
>
确定
</n-button>
</div>
</n-popover>
</div>
<div class="info-item">
<span class="name">邮箱 :</span>
<span class="text">{{ state.email || '-' }}</span>
</div>
<div class="info-item" v-if="state.friend_status == 2">
<span class="name">分组 :</span>
<n-dropdown
trigger="click"
placement="top-start"
:show-arrow="true"
:options="options"
@select="handleSelectGroup"
>
<span class="text edit pointer">{{ groupName }}</span>
</n-dropdown>
</div>
<div class="info-item">
<span class="name">入职时间 :</span>
<span class="text">{{ state.enter_date}}</span>
</div>
</div> </div>
</main> </div>
<div class="bg-#fff rounded-4px mb-20px">
<footer v-if="state.friend_status == 2" class="el-footer footer bdr-t flex-center"> <div class="flex px-15px py-9px">
<n-button <div class="text-#000 text-12px w-84px">公司别</div>
round <div class="text-#747474 text-12px">{{ userInfo.company_name }}</div>
block </div>
type="primary" <div class="flex px-15px py-9px">
text-color="#ffffff" <div class="text-#000 text-12px w-84px">主管</div>
@click="onToTalk" <div class="text-#747474 text-12px">{{ userInfo.leaders?.map(x=>x.user_name)?.join(',') }}</div>
style="width: 91%" </div>
> <div class="flex px-15px py-9px">
<template #icon> <div class="text-#000 text-12px w-84px">部门</div>
<n-icon :component="SendOne" /> <div class="text-#747474 text-12px">{{ userInfo.erp_dept_position?.map(x=>x.department_name)?.join(',') }}</div>
</template> </div>
发送消息 <div class="flex px-15px py-9px">
<div class="text-#000 text-12px w-84px">手机号</div>
<div class="text-#747474 text-12px">{{ userInfo.tel_num }}</div>
</div>
<div class="flex px-15px py-9px">
<div class="text-#000 text-12px w-84px">岗位</div>
<div class="text-#747474 text-12px">{{ userInfo.erp_dept_position?.map(x=>x.position_name)?.join(',') }}</div>
</div>
<div class="flex px-15px py-9px">
<div class="text-#000 text-12px w-84px">入职日期</div>
<div class="text-#747474 text-12px">{{ userInfo.enter_date }}</div>
</div>
</div>
<div>
<n-button block color="#EEE9F8" text-color="#46299D" @click="onToTalk">
<div class="flex items-center justify-center py-11px">
<img class="w-19.8px h-20px mr-15px" src="@/assets/image/faxi@2x.png" alt="">
<span>发送消息</span>
</div>
</n-button> </n-button>
</footer> </div>
</template>
<footer v-else-if="state.friend_status == 1" class="el-footer footer bdr-t flex-center">
<template v-if="isOpenFrom">
<n-input
type="text"
placeholder="请填写申请备注"
v-model:value="state.text"
@keydown.enter="onJoinContact"
/>
<n-button type="primary" text-color="#ffffff" class="mt-l5" @click="onJoinContact">
确定
</n-button>
</template>
<template v-else>
<n-button
type="primary"
text-color="#ffffff"
block
round
style="width: 91%"
@click="isOpenFrom = true"
>
添加好友
</n-button>
</template>
</footer>
</section>
</div> </div>
</n-modal> </x-n-modal>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.section { .section {
width: 100%;
height: 100%;
position: relative; position: relative;
width: 360px; background-image: url('@/assets/image/zu6254@2x.png');
height: 600px;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
background-color: var(--im-bg-color); background-color: var(--im-bg-color);
@ -336,7 +281,6 @@ const onAfterEnter = () => {
height: 230px; height: 230px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(to right, rgb(137, 104, 255), rgb(175, 152, 255));
display: flex; display: flex;
padding: 20px; padding: 20px;
position: relative; position: relative;
@ -346,7 +290,6 @@ const onAfterEnter = () => {
width: 150px; width: 150px;
height: 150px; height: 150px;
content: ''; content: '';
background: linear-gradient(to right, rgb(142, 110, 255), rgb(208, 195, 255));
position: absolute; position: absolute;
z-index: 1; z-index: 1;
border-radius: 50%; border-radius: 50%;

View File

@ -0,0 +1,142 @@
# @x-naive-ui 组件库
基于 Naive UI 的二次封装组件库,旨在提供更高层级的抽象和更便捷的使用方式,同时保持足够的灵活性。
@x-naive-ui 的设计理念是在易用性和灵活性之间找到平衡点,通过合理的默认值和可配置项,能够快速开发出高质量的页面,同时保留足够的扩展空间应对特殊需求。
**如发现文档与实际使用有出入或者不完善 可提交修改**
## 设计理念
### 1. 易用性与灵活性的平衡
- **约定优于配置**:提供合理的默认值,减少基础使用时的配置量
- **保持原有能力**:通过属性透传,保留 Naive UI 原组件的所有功能
- **渐进式配置**:简单场景可以快速使用,复杂场景仍可深度定制
### 2. 通用性与特殊性的权衡
- **场景覆盖**:优先覆盖 80% 的常见业务场景
- **扩展机制**:为剩余 20% 的特殊场景预留扩展接口
### 3.<span style="background-color: red;color:#fff">避免过度封装:不追求完美覆盖所有场景,保持组件的可维护性</span>
## 组件列表
### x-n-data-table
数据表格组件,增强了以下能力:
- ✨ 拖拽排序(支持整行/手柄模式)
- ✨ 列级别的插槽系统
- 🎯 统一的样式和交互
**权衡点**
- 牺牲了一定的性能来换取更好的开发体验
- 固化了部分样式以确保视觉一致性
### x-n-modal
模态框组件,预设了常用配置:
- ✨ 统一的挂载点管理
- ✨ 预设的关闭行为
- 🎯 居中布局和统一样式
**权衡点**
- 限制了一些灵活性以确保使用的一致性
- 强制了某些最佳实践(如挂载点)
### x-n-upload
文件上传组件,增强了以下功能:
- ✨ 统一的文件处理逻辑
- ✨ 内置预览能力
- 🎯 更友好的类型支持
**权衡点**
- 上传接口格式固定,需要后端配合
- 为了通用性,部分特殊格式需要额外处理
### x-search-form
搜索表单组件,提供了:
- ✨ 声明式配置
- ✨ 自动布局
- 🎯 统一的搜索重置行为
**权衡点**
- 牺牲了一些布局灵活性换取使用便利性
- 配置项相对复杂,但换来了更好的复用性
## 最佳实践
### 1. 组件使用建议
```vue
<!-- 推荐:使用声明式配置 -->
<x-search-form
:search-config="searchConfig"
:cols="4"
@change="handleSearch"
/>
<!-- 不推荐:内联复杂配置 -->
<x-search-form
:search-config="[
{ type: 'input', key: 'name', label: '姓名' },
{ type: 'select', key: 'status', label: '状态' }
]"
/>
```
### 2. 配置管理建议
```ts
// 推荐:将配置抽离到单独的配置文件
import { searchConfig } from './config'
import { tableConfig } from './config'
// 不推荐在组件内部直接定义<E5AE9A><E4B989><EFBFBD>杂配置
const searchConfig = [
// ... 大量配置
]
```
## 注意事项
1. **性能考虑**
- 大数据量场景下,优先使用原生组件
- 合理使用 `shallowRef``markRaw`
- 避免不必要的响应式转换
2. **扩展性保证**
- 使用 `v-bind` 透传原组件属性
- 预留合理的插槽接口
- 导出必要的类型定义
3. **代码质量**
- 统一的错误处理机制
- 完善的类型声明
- 详细的文档注释
## 未来规划
1. **组件增强**
- 添加更多常用预设
- 优化性能表现
- 增加更多定制选项
2. **文档完善**
- 补充更多使用示例
- 添加在线演示
- 完善类型声明
3. **工具支持**
- 提供配置生成器
- 添加主题定制能力
- 集成表单验证工具
## 贡献指南
1. **组件开发原则**
- 保持简单性
- 关注通用性
- 预留扩展性
2. **代码规范**
- 遵循项目 ESLint 配置
- 编写单元测试
- 提供完整文档

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,54 @@
<script setup>
import { computed } from "vue";
import levelTwo from "./data/pc-code.json";
import levelThree from "./data/pca-code.json";
import levelFour from "./data/pcas-code.json";
const props = defineProps({
value: {
type: String,
default: undefined
},
label: {
type: String,
default: undefined
},
level: {
type: Number,
default: 3
}
});
const cascaderRef = ref(null);
const emit = defineEmits(['update:value']);
const levelMap = {
2: levelTwo,
3: levelThree,
4: levelFour
};
const options = computed(() => levelMap[props.level] || []);
const updateValue = (value, option) => {
emit("update:value", value);
};
defineExpose({
cascaderRef
});
</script>
<template>
<n-cascader
ref="cascaderRef"
:value="value"
placeholder="请选择"
:options="options"
showPath
check-strategy="child"
value-field="code"
label-field="name"
filterable
@update:value="updateValue"
v-bind="{...$attrs}"
/>
</template>

View File

@ -0,0 +1,251 @@
# @x-n-data-table
基于 Naive UI 的 n-data-table 组件封装,增加了拖拽排序功能和灵活的插槽支持。
## 功能特性
- 支持所有 n-data-table 的原有功能
- 支持列拖拽排序
- 支持拖拽手柄模式
- ✨ 支持每列的自定义插槽
- ✨ 支持列标题的自定义插槽
- 支持自定义拖拽列渲染
## 安装
```bash
# 项目中已经包含此组件,无需额外安装
```
## 插槽功能
> 💡 这是对原生 n-data-table 的重要增强:支持为每一列配置具名插槽
### 列内容插槽
使用列的 `key` 作为插槽名称:
```vue
<template>
<x-n-data-table :columns="columns" :data="data">
<!-- 使用 name 列的插槽 -->
<template #name="{ row, index }">
<n-tag>{{ row.name }}</n-tag>
</template>
<!-- 使用 status 列的插槽 -->
<template #status="{ row }">
<n-badge :status="row.status" />
</template>
</x-n-data-table>
</template>
```
### 列标题插槽
使用 `{key}_title` 作为插槽名称:
```vue
<template>
<x-n-data-table :columns="columns" :data="data">
<!-- 自定义 name 列的标题 -->
<template #name_title>
<n-space>
<n-icon><user /></n-icon>
<span>用户名</span>
</n-space>
</template>
</x-n-data-table>
</template>
```
## 使用方法
```vue
<template>
<x-n-data-table
:columns="columns"
:data="data"
>
<!-- 自定义拖拽列的内容 -->
<template #sort="{ row, index }">
<n-space>
<n-icon>⋮⋮</n-icon>
<span>{{ index + 1 }}</span>
</n-space>
</template>
<!-- 自定义名称列的标题 -->
<template #name_title>
<n-space>
<n-icon><list /></n-icon>
<span>项目名称</span>
</n-space>
</template>
<!-- 自定义名称列的内容 -->
<template #name="{ row }">
<n-ellipsis>
{{ row.name }}
</n-ellipsis>
</template>
</x-n-data-table>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const data = ref([
{ id: 1, name: '项目1' },
{ id: 2, name: '项目2' },
{ id: 3, name: '项目3' }
])
const columns = [
{
key: 'sort',
title: '排序',
type: 'drag',
handle: true,
onDragEnd: ({ oldIndex, newIndex }) => {
const newData = [...data.value]
const [removed] = newData.splice(oldIndex, 1)
newData.splice(newIndex, 0, removed)
data.value = newData
}
},
{
key: 'name',
title: '名称'
}
]
</script>
```
## API
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| columns | `Array<Column \| DragColumn>` | `[]` | 列配置,支持拖拽列 |
| data | `Array<object>` | `[]` | 数据源 |
| align | `string` | `'center'` | 对齐方式 |
其他属性与 n-data-table 保持一致。
### Slots
| 插槽名 | 参数 | 说明 |
|--------|------|------|
| `{key}` | `{ row, index }` | 列内容的自定义渲染key 为列的 key |
| `{key}_title` | - | 列标题的自定义渲染key 为列的 key |
### DragColumn 配置
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| type | `'drag'` | - | 指定为拖拽列 |
| handle | `boolean` | `false` | 是否只能通过手柄拖拽 |
| onDragEnd | `(event: DragSortEvent) => void` | - | 拖拽结束回调 |
### DragSortEvent
| 属性 | 类型 | 说明 |
|------|------|------|
| oldIndex | `number` | 拖拽前的索引 |
| newIndex | `number` | 拖拽后的索引 |
### 方法
组件暴露了以下方法:
| 方法名 | 参数 | 说明 |
|--------|------|------|
| clearFilters | - | 清除过滤条件 |
| clearSorter | - | 清除排序条件 |
| filter | `(filters: any)` | 设置过滤条件 |
| page | `(page: number)` | 跳转到指定页 |
| sort | `(columnKey: string, order: 'ascend' \| 'descend' \| false)` | 设置排序 |
## 注意事项
1. 拖拽列的 `type` 必须设置为 `'drag'`
2. 拖拽功能需要配置 `onDragEnd` 回调来更新数据
3. 建议将拖拽列放在表格的第一列
4. 如果需要禁用整行拖拽,请设置 `handle: true`
5. 插槽名称必须与列的 `key` 对应
6. 标题插槽需要加上 `_title` 后缀
## 示例
### 完整示例
```vue
<template>
<x-n-data-table
:columns="columns"
:data="data"
>
<!-- 拖拽列自定义渲染 -->
<template #sort="{ index }">
<n-space>
<n-icon>⋮⋮</n-icon>
<span>{{ index + 1 }}</span>
</n-space>
</template>
<!-- 名称列标题自定义渲染 -->
<template #name_title>
<n-space>
<n-icon><list /></n-icon>
<span>项目名称</span>
</n-space>
</template>
<!-- 名称列内容自定义渲染 -->
<template #name="{ row }">
<n-ellipsis>
{{ row.name }}
</n-ellipsis>
</template>
<!-- 状态列自定义渲染 -->
<template #status="{ row }">
<n-tag :type="row.status">
{{ row.statusText }}
</n-tag>
</template>
</x-n-data-table>
</template>
<script setup lang="ts">
const data = ref([
{ id: 1, name: '项目1', status: 'success', statusText: '正常' },
{ id: 2, name: '项目2', status: 'warning', statusText: '警告' }
])
const columns = [
{
key: 'sort',
title: '排序',
type: 'drag',
handle: true,
onDragEnd: ({ oldIndex, newIndex }) => {
const newData = [...data.value]
const [removed] = newData.splice(oldIndex, 1)
newData.splice(newIndex, 0, removed)
data.value = newData
}
},
{
key: 'name',
title: '名称'
},
{
key: 'status',
title: '状态'
}
]
</script>
```

View File

@ -0,0 +1,215 @@
<script setup>
import {
h,
useSlots,
computed,
shallowRef,
onMounted,
onUnmounted,
nextTick,
markRaw,
watch,
} from "vue";
import Sortable from "sortablejs";
import { debounce } from "lodash-es";
import { NDataTable } from "naive-ui";
// Props
const props = defineProps({
columns: {
type: Array,
default: () => [],
required: true,
},
data: {
type: Array,
default: () => [],
required: true,
},
align: {
type: String,
default: "center",
validator: (value) => ["left", "center", "right"].includes(value),
},
});
/**
* 拖拽功能相关逻辑
*/
const useDraggable = (props, emit) => {
const dragConfig = {
handleId: "drag-handle",
handleStyle: { cursor: "move", padding: "0 4px" },
};
const dragColumn = computed(() =>
props.columns?.find((col) => col.type === "drag")
);
const sortable = shallowRef();
const nDataTableRef = shallowRef();
const initSortable = () => {
if (!dragColumn.value) return;
const tbody = nDataTableRef.value?.$el?.querySelector?.(
".n-data-table-tbody"
);
if (!tbody) return;
sortable.value = markRaw(
new Sortable(tbody, {
animation: 150,
handle: dragColumn.value.handle ? `.${dragConfig.handleId}` : undefined,
onEnd: ({ oldIndex, newIndex }) => {
if (oldIndex === newIndex) return;
const newData = [...props.data];
const [removed] = newData.splice(oldIndex, 1);
newData.splice(newIndex, 0, removed);
emit("update:data", newData);
dragColumn.value?.onDragEnd?.({
oldIndex,
newIndex,
data: newData,
row: removed,
});
},
})
);
};
const debouncedInitSortable = debounce(initSortable, 200);
onMounted(() => {
nextTick(initSortable);
});
onUnmounted(() => {
sortable.value?.destroy();
});
watch(
() => props.data,
() => nextTick(debouncedInitSortable)
);
return {
dragConfig,
dragColumn,
nDataTableRef,
};
};
/**
* 列渲染相关逻辑
*/
const useColumns = (props, slots, dragConfig) => {
//
const createTitle = (column, slotKey) => {
const titleSlotKey = `${slotKey}_title`;
if (column.titleRender) {
return column.titleRender;
}
if (slots[titleSlotKey]) {
return () => slots[titleSlotKey]({ column });
}
return column.title;
};
//
const createExpandRenderer = () => {
if (!slots["templateExpand"]) return null;
return (row, index) => h(slots["templateExpand"], { row, index });
};
//
const createDragColumnRenderer = (column, slotKey) => {
return (row, index) =>
h(
"div",
{
class: [dragConfig.handleId, "drag-handle-wrapper"],
style: dragConfig.handleStyle,
onClick: column.handle ? (e) => e.stopPropagation() : undefined,
},
slots[slotKey] ? slots[slotKey]({ row, index, column }) : "⋮⋮"
);
};
//
const createDefaultColumnRenderer = (column, slotKey) => {
if (slots[slotKey]) {
return (row, index) => slots[slotKey]({ row, index, column });
}
if (column.render) {
return column.render;
}
return (row) => row[column.key];
};
//
const createColumnRender = (column) => {
const slotKey = column.key;
const baseColumn = {
...column,
align: props.align,
title: createTitle(column, slotKey),
renderExpand: createExpandRenderer(),
};
if (column.type === "drag") {
return {
...baseColumn,
render: createDragColumnRenderer(column, slotKey),
};
}
return {
...baseColumn,
render: createDefaultColumnRenderer(column, slotKey),
};
};
return computed(() => props.columns?.map(createColumnRender) || []);
};
//
const emit = defineEmits(["update:data"]);
const slots = useSlots();
//
const { dragConfig, dragColumn, nDataTableRef } = useDraggable(props, emit);
const computedColumns = useColumns(props, slots, dragConfig);
</script>
<template>
<n-data-table
ref="nDataTableRef"
:class="[dragColumn?.handle ? 'handle-only-drag' : 'full-row-drag']"
remote
v-bind="{ ...$attrs, ...$props, columns: computedColumns }"
/>
</template>
<style scoped>
.drag-handle-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.handle-only-drag tbody tr {
cursor: default;
}
.full-row-drag tbody tr {
cursor: move;
}
</style>

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