Compare commits

..

No commits in common. "dbdec912cea76b0ea76518a784a75abbeb20562c" and "950ca2876cf482d393e7aca505b2b4636521d14a" have entirely different histories.

152 changed files with 3704 additions and 23984 deletions

8
.env Normal file
View File

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

6
.env.electron Normal file
View File

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

23
.eslintrc.cjs Normal file
View File

@ -0,0 +1,23 @@
/* 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,5 +24,3 @@ makefile
*.njsproj
*.sln
*.sw?
components.d.ts
auto-imports.d.ts

View File

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

143
README.md
View File

@ -1,104 +1,87 @@
# IM - 在线即时通讯应用
# Lumen IM 即时聊天
IM 是一个基于 Vue 3 开发的现代化在线即时通讯应用,提供实时聊天、消息管理、笔记等功能。
<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">
## 功能特性
### 项目介绍
- 📱 实时聊天:支持一对一即时通讯
- 📝 消息管理:高效管理各类消息
- 📓 笔记功能支持Markdown格式的笔记编辑与管理
- 🌓 暗色模式:支持明暗主题切换,呵护您的眼睛
- 🔒 用户认证:完善的登录注册系统
Lumen IM 是一个网页版在线聊天项目,前端使用 Naive UI + Vue3后端采用 GO 开发。
## 技术栈
### 功能模块
- **前端框架**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
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 配置信息
```bash
# 测试环境
pnpm dev:test
# 生产环境
pnpm dev:prod
```env
VITE_BASE_API=http://127.0.0.1:8503
VITE_SOCKET_API=ws://127.0.0.1:8504
```
### 打包构建
###### 关于 Nginx 的一些配置
```bash
# 测试环境构建
pnpm build:test
```nginx
server {
listen 80;
server_name www.yourdomain.com;
# 生产环境构建
pnpm build:prod
root /project-path/dist;
index index.html;
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 |
## 项目结构
#### 联系方式
```
src/
├── api/ # API请求
├── assets/ # 静态资源
├── components/ # 公共组件
├── connect.ts # WebSocket连接管理
├── constant/ # 常量定义
├── directive/ # 自定义指令
├── event/ # 事件管理
├── hooks/ # 自定义钩子
├── layout/ # 布局组件
├── main.ts # 入口文件
├── plugins/ # 插件配置
├── router/ # 路由配置
├── store/ # 状态管理
├── types/ # 类型定义
├── utils/ # 工具函数
└── views/ # 页面视图
```
QQ作者 : 837215079
## 环境变量配置
项目支持不同环境配置,环境变量文件位于`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
### 如果你觉得还不错,请 Star , Fork 给作者鼓励一下。

92
electron/main.js Normal file
View File

@ -0,0 +1,92 @@
// 控制应用生命周期和创建原生浏览器窗口的模组
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)
})

46
electron/preload.js Normal file
View File

@ -0,0 +1,46 @@
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])
}
})

11
env/.env.test vendored
View File

@ -1,11 +0,0 @@
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" />
<link rel="icon" href="./src/assets/image/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title> 在线聊天</title>
<title>Lumen IM 在线聊天</title>
<style>
.outer,
.middle,

9309
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,33 @@
{
"name": "IM",
"name": "LumenIM",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "electron/main.js",
"scripts": {
"dev:test": "vite --mode test --port 5273",
"dev:prod": "vite --mode prod --port 5273",
"build:test": "vite build --mode test",
"build:prod": "vite build --mode test",
"dev": "vite --mode development --port 5273",
"build": "vite build",
"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",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@highlightjs/vue-plugin": "^2.1.0",
"@iconify-json/ion": "^1.2.3",
"@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",
"@vueuse/core": "^10.7.0",
"ant-design-vue": "^4.2.6",
"axios": "^1.6.2",
"highlight.js": "^11.5.0",
"js-audio-recorder": "^1.0.7",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"pnpm": "^10.10.0",
"quill": "^1.3.7",
"quill-image-uploader": "^1.3.0",
"quill-mention": "^4.1.0",
"sortablejs": "^1.15.6",
"viewerjs": "^1.11.7",
"vue": "^3.3.11",
"vue-cropper": "^1.1.1",
"vue-router": "^4.2.5",
@ -45,41 +37,44 @@
},
"devDependencies": {
"@icon-park/vue-next": "^1.4.2",
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node18": "^18.2.2",
"@types/node": "^18.18.5",
"@types/vue": "^2.0.0",
"@unocss/reset": "^66.1.1",
"@vitejs/plugin-vue": "^4.4.0",
"@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",
"concurrently": "^7.3.0",
"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-loader": "^11.1.3",
"naive-ui": "^2.35.0",
"npm-run-all2": "^6.1.1",
"prettier": "^3.1.0",
"sass": "^1.88.0",
"typescript": "~5.2.0",
"unocss": "0.58.0",
"unplugin-auto-import": "^19.2.0",
"unplugin-vue-components": "^28.5.0",
"vite": "^6.3.5",
"vite": "^4.5.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.7.6",
"vue-tsc": "^1.8.25",
"wait-on": "^6.0.1"
},
"build": {
"appId": "com.gzydong.im",
"productName": "IM",
"copyright": "Copyright © 2023 IM",
"appId": "com.gzydong.lumenim",
"productName": "LumenIM",
"copyright": "Copyright © 2023 LumenIM",
"mac": {
"category": "public.app-category.utilities",
"icon": "build/icons/-im-mac.png"
"icon": "build/icons/lumen-im-mac.png"
},
"win": {
"icon": "build/icons/-im-mac.png",
"icon": "build/icons/lumen-im-mac.png",
"target": [
{
"target": "nsis"
@ -89,12 +84,20 @@
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerIcon": "build/icons/-im-win.ico",
"uninstallerIcon": "build/icons/-im-win.ico",
"installerHeaderIcon": "build/icons/-im-win.ico",
"installerIcon": "build/icons/lumen-im-win.ico",
"uninstallerIcon": "build/icons/lumen-im-win.ico",
"installerHeaderIcon": "build/icons/lumen-im-win.ico",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"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'
})
const { uid: showUserId, isShow: isShowUser,euid } = useProvideUserModal()
const { uid: showUserId, isShow: isShowUser } = useProvideUserModal()
const { getDarkTheme, getThemeOverride } = useThemeMode()
const userStore = useUserStore()
@ -94,7 +94,6 @@ useClickEvent()
<UserCardModal
v-model:show="isShowUser"
v-model:uid="showUserId"
:euid="euid"
@update-remark="onChangeRemark"
/>
</n-layout-content>

View File

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

View File

@ -9,10 +9,7 @@ export const ServeGetTalkList = (data = {}) => {
export const ServeCreateTalkList = (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 = {}) => {
return post('/api/v1/talk/delete', data)
@ -89,13 +86,3 @@ export const ServeSendVote = (data = {}) => {
export const ServeConfirmVoteHandle = (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)
}

View File

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

View File

@ -77,11 +77,6 @@ export const ServeEditGroupNotice = (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) => {
return get('/api/v1/group/apply/list', data)
}

View File

@ -1,18 +0,0 @@
// 使用 `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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,68 +0,0 @@
/* 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.

Before

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,144 +0,0 @@
<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';
position: absolute;
display: none;
color: #462AA0;
color: #1890ff;
text-align: center;
vertical-align: middle;
opacity: 0;
@ -177,7 +177,7 @@
display: block;
width: 9px;
height: 9px;
background-color: #462AA0;
background-color: #1890ff;
border-radius: 100%;
-webkit-transform: scale(0.75);
transform: scale(0.75);

View File

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

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

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

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

View File

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

View File

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

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

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

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

@ -1,783 +0,0 @@
<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,21 +5,22 @@ import { ServeGetForwardRecords } from '@/api/chat'
import { MessageComponents } from '@/constant/message'
import { ITalkRecord } from '@/types/chat'
import { useInject } from '@/hooks'
import customModal from '@/components/common/customModal.vue'
import { voiceToText } from '@/api/chat.js'
const emit = defineEmits(['close'])
const props = defineProps({
msgId: {
type: String,
required: true
}
})
const isShow=defineModel<boolean>('show')
const { showUserInfoModal } = useInject()
const isShow = ref(true)
const items = ref<ITalkRecord[]>([])
const title = ref('会话记录')
const onMaskClick = () => {
isShow.value=false
emit('close')
}
const onLoadData = () => {
@ -29,92 +30,18 @@ const onLoadData = () => {
if (res.code == 200) {
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(() => {
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>
<template>
<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
<n-modal
v-model:show="isShow"
preset="card"
:title="title"
@ -153,7 +80,7 @@ const onContextMenu = (e:any,item: ITalkRecord) => {
</div>
</div>
</div>
</n-modal> -->
</n-modal>
</template>
<style lang="less" scoped>
@ -167,12 +94,10 @@ const onContextMenu = (e:any,item: ITalkRecord) => {
min-height: 38px;
display: flex;
margin-bottom: 10px;
padding: 24px 42px;
.im-message-text{
background-color: #fff;
}
padding: 5px 15px;
.left-box {
width: 38px;
width: 30px;
display: flex;
user-select: none;
padding-top: 8px;

View File

@ -3,7 +3,7 @@ import { ref, reactive } from 'vue'
import { PlayOne, PauseOne } from '@icon-park/vue-next'
import { ITalkRecordExtraAudio, ITalkRecord } from '@/types/chat'
const props = defineProps<{
defineProps<{
extra: ITalkRecordExtraAudio
data: ITalkRecord
maxWidth?: Boolean
@ -18,8 +18,7 @@ const state = reactive({
progress: 0,
duration: 0,
currentTime: 0,
loading: true,
showText: false
loading: true
})
const onPlay = () => {
@ -41,12 +40,6 @@ const onCanplay = () => {
state.duration = audioRef.value.duration
durationDesc.value = formatTime(parseInt(audioRef.value.duration))
state.loading = false
if (props.data.is_convert_text === 1 && props.data.extra.content) {
setTimeout(() => {
state.showText = true
}, 300)
}
}
const onError = (e: any) => {
@ -68,12 +61,17 @@ const formatTime = (value: number = 0) => {
return '-'
}
return `${Math.floor(value)}"`
const minutes = Math.floor(value / 60)
let seconds = value
if (minutes > 0) {
seconds = Math.floor(value - minutes * 60)
}
return `${minutes}'${seconds}"`
}
</script>
<template>
<div class="pointer w-200px bg-#f5f5f5 rounded-10px px-11px">
<div class="im-message-audio h-44px">
<div class="im-message-audio">
<audio
ref="audioRef"
preload="auto"
@ -100,27 +98,20 @@ const formatTime = (value: number = 0) => {
</div>
<div class="time">{{ durationDesc }}</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>
<style lang="less" scoped>
.im-message-audio {
--audio-bg-color: #f5f5f5;
--audio-btn-bg-color: #ffffff;
width: 200px;
height: 45px;
border-radius: 10px;
display: flex;
align-items: center;
overflow: hidden;
background-color: var(--audio-bg-color);
> div {
display: flex;
align-items: center;
@ -141,7 +132,6 @@ const formatTime = (value: number = 0) => {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
}
@ -240,7 +230,6 @@ const formatTime = (value: number = 0) => {
height: 70%;
width: 1px;
background-color: #9b9595;
transition: left 0.1s linear;
}
}
@ -252,40 +241,6 @@ 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'] {
.im-message-audio {
--audio-bg-color: #2c2c32;

View File

@ -1,260 +1,118 @@
<script setup>
<script lang="ts" setup>
import { fileFormatSize } from '@/utils/strings'
import { ref, computed } from 'vue'
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
}
})
import { download, getFileNameSuffix } from '@/utils/functions'
import { ITalkRecordExtraFile, ITalkRecord } from '@/types/chat'
const uploadsStore = useUploadsStore()
const isPlaying = ref(false)
//
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)
}
defineProps<{
extra: ITalkRecordExtraFile
data: ITalkRecord
maxWidth?: Boolean
}>()
</script>
<template>
<div class="file-message flex flex-col" @click="handleClick">
<!-- 文件头部信息 -->
<div class="file-header">
<!-- 文件名 -->
<div class="file-name">{{ extra.name }}</div>
<!-- 文件图标区域 -->
<div class="file-icon-container">
<img class="file-icon" :src="fileInfo.icon" alt="文件图标">
<!-- 上传进度圆环 - 上传状态 -->
<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>
<section class="file-message">
<div class="main">
<div class="ext">{{ getFileNameSuffix(extra.name) }}</div>
<div class="file-box">
<p class="info">
<span class="name">{{ extra.name }}</span>
<span class="size">({{ fileFormatSize(extra.size) }})</span>
</p>
<p class="notice">文件已成功发送, 文件助手永久保存</p>
</div>
</div>
<!-- 文件大小信息 -->
<div class="flex justify-between items-center grow-1">
<div class="file-size">{{ fileFormatSize(extra.size) }}</div>
<div class="flex items-center" v-if="!extra.is_uploading">
<div class="flex items-center" @click.stop="handleDownload"> <img class="w-11.7px h-11.74px mr-7px" src="@/assets/image/dofd.png" alt=""> <span class="text-12px text-#46299D">下载</span></div>
</div>
<div class="footer">
<a @click="download(data.msg_id)">下载</a>
<a>在线预览</a>
</div>
</div>
</section>
</template>
<style lang="less" scoped>
.file-message {
width: 243px;
background-color: #fff;
height: 110px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 0 14px;
cursor: pointer;
}
width: 250px;
min-height: 85px;
padding: 10px;
border-radius: 10px;
border: 1px solid var(--im-message-border-color);
.file-header {
display: flex;
padding: 14px 5px 14px 0;
justify-content: space-between;
width: 100%;
border-bottom: 1px solid #EEEEEE;
}
.main {
height: 45px;
display: flex;
flex-direction: row;
margin-top: 5px;
.file-name {
height: 50px;
color: #1A1A1A;
font-size: 14px;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.ext {
display: flex;
justify-content: center;
align-items: center;
width: 45px;
height: 45px;
color: #ffffff;
background: #49a4ff;
border-radius: 5px;
font-size: 12px;
}
.file-icon-container {
height: 48px;
position: relative;
}
.file-box {
flex: 1 1;
height: 45px;
margin-left: 10px;
overflow: hidden;
.file-icon {
width: 48px;
height: 48px;
}
.info {
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
height: 24px;
font-size: 14px;
.progress-overlay {
background-color: #fff;
position: absolute;
top: 6px;
left: 11px;
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
}
.name {
flex: 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
color: #747474;
font-size: 12px;
}
.size {
font-size: 12px;
color: #cac6c6;
flex-shrink: 0;
}
}
.circle-progress-container {
width: 20px;
height: 20px;
position: relative;
cursor: pointer;
}
.notice {
height: 25px;
line-height: 25px;
font-size: 12px;
color: #929191;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.circle-progress {
transform: rotate(-90deg);
transform-origin: center;
}
.footer {
height: 30px;
line-height: 37px;
text-align: right;
font-size: 12px;
border-top: 1px solid var(--border-color);
margin-top: 10px;
.progress-circle {
transition: stroke-dashoffset 0.3s ease;
}
a {
margin: 0 3px;
user-select: none;
cursor: pointer;
color: var(--im-text-color);
.pause-icon, .play-icon {
transform-origin: center;
}
.pause-icon {
transform: rotate(90deg);
&:hover {
color: royalblue;
}
}
}
}
</style>

View File

@ -33,7 +33,7 @@ const onClick = () => {
<span>转发聊天会话记录 ({{ extra.msg_ids.length }})</span>
</div>
<ForwardRecord v-model:show="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" />
<ForwardRecord v-if="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" />
</section>
</template>

View File

@ -35,7 +35,7 @@ const img = (src: string, width = 200) => {
:class="{ left: data.float === 'left' }"
:style="img(extra.url, 350)"
>
<n-image class="h-149px" :src="extra.url" />
<n-image :src="extra.url" />
</section>
</template>
<style lang="less" scoped>
@ -44,7 +44,9 @@ const img = (src: string, width = 200) => {
padding: 5px;
border-radius: 5px;
background: var(--im-message-left-bg-color);
height:149px
min-width: 30px;
min-height: 30px;
&.left {
background: var(--im-message-right-bg-color);
}

View File

@ -1,95 +0,0 @@
<!-- 完全复制的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,9 +1,7 @@
<script setup>
import { formatTime } from '@/utils/datetime'
import { bus } from '@/utils/event-bus'
import { EditorConst } from '@/constant/event-bus'
const props = defineProps({
defineProps({
login_uid: {
type: Number,
default: 0
@ -23,30 +21,13 @@ const props = defineProps({
datetime: {
type: String,
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>
<template>
<div class="im-message-revoke">
<div class="content">
<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-if="login_uid == user_id"> 你撤回了一条消息 | {{ formatTime(datetime) }} </span>
<span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span>
<span v-else>
"{{ nickname }}" 撤回了一条消息 |

View File

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

View File

@ -1,17 +1,11 @@
<script lang="ts" setup>
import 'xgplayer/dist/index.min.css'
import { ref, nextTick, watch } from 'vue'
import { NImage, NModal, NCard, NProgress, NPopconfirm } from 'naive-ui'
import { Play, Close, Pause, Right, Attention } from '@icon-park/vue-next'
import { ref, nextTick } from 'vue'
import { NImage, NModal, NCard } from 'naive-ui'
import { Play, Close } from '@icon-park/vue-next'
import { getImageInfo } from '@/utils/functions'
import {PauseOutline} from '@vicons/ionicons5'
import Player from 'xgplayer'
import { ITalkRecordExtraVideo, ITalkRecord } from '@/types/chat'
import { useUploadsStore } from '@/store'
// @ts-ignore
const message = window.$message
const uploadsStore = useUploadsStore()
const props = defineProps<{
extra: ITalkRecordExtraVideo
@ -19,70 +13,35 @@ const props = defineProps<{
maxWidth?: Boolean
}>()
// const img = (src: string, width = 200) => {
// const info: any = getImageInfo(src)
const img = (src: string, width = 200) => {
const info: any = getImageInfo(src)
// if (info.width == 0 || info.height == 0) {
// return {}
// }
if (info.width == 0 || info.height == 0) {
return {}
}
// if (info.height > 300) {
// return {
// 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.height > 300) {
return {
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'
}
}
//
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 })
const open = ref(false)
async function onPlay() {
//
if (props.extra.is_uploading) {
return
}
open.value = true
await nextTick()
@ -95,86 +54,18 @@ async function onPlay() {
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>
<template>
<section
class="im-message-video"
:class="{ left: data.float === 'left' }"
:style="img(extra.cover, 350)"
@click="onPlay"
>
<!-- <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" >
<n-image :src="extra.cover" preview-disabled />
</div>
<!-- <n-icon
v-else
class="control-btn"
:component="Right"
size="20"
@click="resumeUpload"
/> -->
</div>
</div>
<!-- 播放按钮仅在视频不是上传状态且未失败时显示 -->
<div v-if="!extra.is_uploading && !uploadFailed" class="btn-video">
<n-icon :component="Play" size="40" />
<div class="btn-video">
<n-icon :component="Play" size="36" />
</div>
<n-modal v-model:show="open">
@ -201,25 +92,23 @@ function resumeUpload(e) {
min-height: 30px;
display: inline-flex;
position: relative;
height:149px;
width: 225px;
&.left {
background: var(--im-message-right-bg-color);
}
video {
:deep(.n-image img) {
width: 100%;
height: 100%;
border-radius: 5px;
object-fit: cover;
background-color: #333; /* 添加背景色,避免默认显示为灰色 */
}
.btn-video {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 30px;
height: 20px;
position: absolute;
left: calc(50% - 15px);
top: calc(50% - 10px);
cursor: pointer;
color: #ffffff;
}
@ -245,66 +134,4 @@ function resumeUpload(e) {
align-items: 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>

View File

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

View File

@ -1,25 +0,0 @@
<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,7 +13,6 @@ const { showUserInfoModal } = useInject()
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>

View File

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

@ -1,25 +0,0 @@
<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>
</template>
<span>出群聊</span>
<span>出群聊</span>
</div>
</div>
</template>

View File

@ -1,23 +0,0 @@
<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;
&:hover {
color: #462AA0;
color: #1890ff;
}
}
}

View File

@ -1,10 +1,12 @@
<script lang="ts" setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ServeGetTalkList } from '@/api/chat.js'
import { ref, computed } from 'vue'
import { NModal, NInput, NScrollbar, NCheckbox, NTabs, NTab } from 'naive-ui'
import { Search, Delete } from '@icon-park/vue-next'
import { ServeGetContacts } from '@/api/contact'
import { ServeGetGroups } from '@/api/group'
import XNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
const emit = defineEmits(['close', 'on-submit'])
import { CloseCircle } from '@vicons/ionicons5'
interface Item {
id: number
type: number
@ -15,18 +17,16 @@ interface Item {
keyword: string
}
const isShowBox = defineModel('show')
const tabsIndex = ref<number>(1)
const isShowBox = ref(true)
const loading = ref(true)
const items = ref<Item[]>([])
const keywords = ref('')
const loadGroupStatus = ref(false)
defineProps<{
forwardMode: number
}>()
//
const searchFilter = computed(() => {
return items.value.filter((item: Item) => {
return item.name.toLowerCase().includes(keywords.value.toLowerCase())
return tabsIndex.value == item.type && item.keyword.match(keywords.value) != null
})
})
@ -40,19 +40,23 @@ const isCanSubmit = computed(() => {
const onLoad = () => {
onLoadContact()
// onLoadGroup()
}
const onLoadContact = () => {
loading.value = true
ServeGetTalkList()
ServeGetContacts()
.then((res) => {
if (res.code == 200) {
let list = res.data.items || []
items.value = list.map((item: any) => {
return {
...item,
id: item.id,
avatar: item.avatar,
type: 1,
name: item.remark || item.nickname,
keyword: item.remark + item.nickname,
remark: item.remark,
checked: false
}
})
@ -63,48 +67,40 @@ const onLoadContact = () => {
})
}
// const onLoadGroup = async () => {
// if (loadGroupStatus.value) {
// return
// }
const onLoadGroup = async () => {
if (loadGroupStatus.value) {
return
}
// loading.value = true
// let { code, data } = await ServeGetGroups()
// if (code != 200) {
// loading.value = false
// return
// }
loading.value = true
let { code, data } = await ServeGetGroups()
if (code != 200) {
return
}
// let list = data.items.map((item: any) => {
// return {
// id: item.id,
// avatar: item.avatar,
// type: 2,
// name: item.group_name,
// keyword: item.group_name,
// remark: '',
// checked: false
// }
// })
let list = data.items.map((item: any) => {
return {
id: item.id,
avatar: item.avatar,
type: 2,
name: item.group_name,
keyword: item.group_name,
remark: '',
checked: false
}
})
// items.value.push(...list)
items.value.push(...list)
// loading.value = false
// loadGroupStatus.value = true
// }
loading.value = false
loadGroupStatus.value = true
}
const onMaskClick = () => {
emit('close')
}
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)
if (data) {
@ -112,152 +108,230 @@ 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 = () => {
let data = checkedFilter.value.map((item: any) => {
return {
receiver_id: item.receiver_id,
talk_type: item.talk_type
id: item.id,
type: item.type
}
})
console.log('data', data);
console.log('checkedFilter.value', checkedFilter.value);
emit('on-submit', data)
}
// 1 2
const selectType = ref(1)
const changeSelectType = () => {
selectType.value = selectType.value == 1 ? 2 : 1
//
items.value.forEach(item => {
item.checked = false
})
const onTabs = (value: number) => {
tabsIndex.value = value
if (value == 2) {
onLoadGroup()
}
}
watch(()=>{
return isShowBox.value
},(newVal)=>{
if(newVal){
onLoad()
}
})
onLoad()
</script>
<template>
<x-n-modal v-model:show="isShowBox" :title="forwardMode === 2 ? '合并转发' : '逐条转发'" style="width: 997px; height: 740px;background-color: #F9F9FD"
:on-after-leave="onMaskClick" content-style="display: flex; justify-content: center; align-items: center;">
<div class="w-927px h-627px bg-#fff rounded-3px px-35px py-20px">
<div class="flex items-center justify-between mb-28px">
<div class="text-#333639">搜索</div>
<div class="w-779px h-34px">
<n-input v-model:value="keywords" type="text" clearable placeholder="请输入">
</n-input>
</div>
</div>
<div class="flex justify-between">
<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" />
<n-modal
v-model:show="isShowBox"
preset="card"
title="选择联系人"
class="modal-radius"
style="max-width: 650px; height: 550px"
:on-after-leave="onMaskClick"
:segmented="{
content: true,
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="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 class="content">
<span class="text-ellipsis">{{ item.remark || item.name }}</span>
</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 class="checkbox">
<n-checkbox size="small" :checked="item.checked" />
</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>
</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>
</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 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>
</x-n-modal>
</template>
</n-modal>
</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,16 +1,18 @@
<script lang="ts" setup>
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 { ServeSearchUser } from '@/api/contact'
import { ServeCreateContact } from '@/api/contact'
import { ServeContactGroupList, ServeContactMoveGroup, ServeEditContactRemark } from '@/api/contact'
import { useTalkStore } from '@/store'
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 talkStore = useTalkStore()
const emit = defineEmits(['update:show', 'update:uid', 'updateRemark'])
const props = defineProps({
show: {
type: Boolean,
@ -19,16 +21,12 @@ const props = defineProps({
uid: {
type: Number,
default: 0
},
euid: {
type: Number,
default: 0
}
})
const loading = ref(true)
const isOpenFrom = ref(false)
const userInfo: any = ref({
const state: any = reactive({
id: 0,
avatar: '',
gender: 0,
@ -45,26 +43,26 @@ const editCardPopover: any = ref(false)
const modelRemark = ref('')
const options = ref<any>([])
// const groupName = computed(() => {
// const item = options.value.find((item: any) => {
// return item.key == state.group_id
// })
const groupName = computed(() => {
const item = options.value.find((item: any) => {
return item.key == state.group_id
})
// if (item) {
// return item.label
// }
if (item) {
return item.label
}
// return ''
// })
return '未设置分组'
})
const onLoadData = () => {
ServeSearchUser({
erp_user_id: props.euid
user_id: props.uid
}).then(({ code, data }) => {
if (code == 200) {
userInfo.value = data
Object.assign(state, data)
// modelRemark.value = state.remark
modelRemark.value = state.remark
loading.value = false
} else {
@ -72,15 +70,15 @@ const onLoadData = () => {
}
})
// ServeContactGroupList().then((res) => {
// if (res.code == 200) {
// let items = res.data.items || []
// options.value = []
// for (const iter of items) {
// options.value.push({ label: iter.name, key: iter.id })
// }
// }
// })
ServeContactGroupList().then((res) => {
if (res.code == 200) {
let items = res.data.items || []
options.value = []
for (const iter of items) {
options.value.push({ label: iter.name, key: iter.id })
}
}
})
}
const onToTalk = () => {
@ -88,190 +86,247 @@ const onToTalk = () => {
emit('update:show', false)
}
// const onJoinContact = () => {
// if (!state.text.length) {
// return window['$message'].info('')
// }
const onJoinContact = () => {
if (!state.text.length) {
return window['$message'].info('备注信息不能为空')
}
// ServeCreateContact({
// friend_id: props.uid,
// remark: state.text
// }).then((res) => {
// if (res.code == 200) {
// isOpenFrom.value = false
// window['$message'].success('')
// } else {
// window['$message'].error(res.message)
// }
// })
// }
ServeCreateContact({
friend_id: props.uid,
remark: state.text
}).then((res) => {
if (res.code == 200) {
isOpenFrom.value = false
window['$message'].success('申请发送成功')
} else {
window['$message'].error(res.message)
}
})
}
// const onChangeRemark = () => {
// ServeEditContactRemark({
// friend_id: props.uid,
// remark: modelRemark.value
// }).then(({ code, message }) => {
// if (code == 200) {
// editCardPopover.value.setShow(false)
// window['$message'].success('')
// state.remark = modelRemark.value
const onChangeRemark = () => {
ServeEditContactRemark({
friend_id: props.uid,
remark: modelRemark.value
}).then(({ code, message }) => {
if (code == 200) {
editCardPopover.value.setShow(false)
window['$message'].success('备注成功')
state.remark = modelRemark.value
// emit('updateRemark', {
// user_id: props.uid,
// remark: modelRemark.value
// })
// } else {
// window['$message'].error(message)
// }
// })
// }
emit('updateRemark', {
user_id: props.uid,
remark: modelRemark.value
})
} else {
window['$message'].error(message)
}
})
}
// const handleSelectGroup = (value) => {
// ServeContactMoveGroup({
// user_id: props.uid,
// group_id: value
// }).then(({ code, message }) => {
// if (code == 200) {
// state.group_id = value
// window['$message'].success('')
// } else {
// window['$message'].error(message)
// }
// })
// }
const handleSelectGroup = (value) => {
ServeContactMoveGroup({
user_id: props.uid,
group_id: value
}).then(({ code, message }) => {
if (code == 200) {
state.group_id = value
window['$message'].success('分组修改成功')
} else {
window['$message'].error(message)
}
})
}
// const reset = () => {
// loading.value = true
const reset = () => {
loading.value = true
// Object.assign(state, {
// id: 0,
// avatar: '',
// gender: 0,
// mobile: '',
// motto: '',
// nickname: '',
// remark: '',
// email: '',
// status: 1,
// text: ''
// })
Object.assign(state, {
id: 0,
avatar: '',
gender: 0,
mobile: '',
motto: '',
nickname: '',
remark: '',
email: '',
status: 1,
text: ''
})
// isOpenFrom.value = false
// }
isOpenFrom.value = false
}
// const onUpdate = (value) => {
// if (!value) {
// setTimeout(reset, 100)
// }
const onUpdate = (value) => {
if (!value) {
setTimeout(reset, 100)
}
// emit('update:show', value)
// }
emit('update:show', value)
}
const onAfterEnter = () => {
onLoadData()
}
const onAfterLeave = () => {
// loading.value = true
userInfo.value = {
id: 0,
avatar: '',
gender: 0,
mobile: '',
motto: '',
nickname: '',
remark: '',
email: '',
status: 1,
text: ''
}
}
</script>
<template>
<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 relative px-7px pt-82px pb-20px">
<div class="absolute top-9px right-7px pointer z-10" @click="emit('update:show', false)">
<img class="w-20px h-20px" src="@/assets/image/close.png" alt="">
</div>
<template v-if="loading">
<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">
<n-skeleton height="59px" width="59px" />
</div>
<div class="w-full">
<n-skeleton text style="width: 80%; margin-bottom: 5px;" />
<n-skeleton text style="width: 60%;" />
</div>
</div>
<div class="bg-#fff rounded-4px mb-20px">
<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>
<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" >
<n-modal :show="show" :on-update:show="onUpdate" :on-after-enter="onAfterEnter">
<div class="section" v-loading="loading">
<section class="el-container container is-vertical">
<header class="el-header header">
<im-avatar
class="avatar"
:size="100"
:src="state.avatar"
:username="state.remark || state.nickname"
:font-size="30"
/>
</n-image>
<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="text-#000 text-16px mb-5px">{{ userInfo.nickname }}</div>
<div class="text-#ACACAC text-12px">工号{{ userInfo.job_num }}</div>
<div class="close" @click="onUpdate(false)">
<close-one theme="outline" size="22" fill="#fff" :strokeWidth="2" />
</div>
</div>
<div class="bg-#fff rounded-4px mb-20px">
<div class="flex px-15px py-9px">
<div class="text-#000 text-12px w-84px">公司别</div>
<div class="text-#747474 text-12px">{{ userInfo.company_name }}</div>
<div class="nickname text-ellipsis">
{{ state.remark || state.nickname || '未设置昵称' }}
</div>
<div class="flex px-15px py-9px">
<div class="text-#000 text-12px w-84px">主管</div>
<div class="text-#747474 text-12px">{{ userInfo.leaders?.map(x=>x.user_name)?.join(',') }}</div>
</header>
<main class="el-main main me-scrollbar me-scrollbar-thumb">
<div class="motto">
{{ state.motto || '编辑个签,展示我的独特态度。' }}
</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.department_name)?.join(',') }}</div>
<div class="infos">
<div class="info-item">
<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 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>
</main>
<footer v-if="state.friend_status == 2" class="el-footer footer bdr-t flex-center">
<n-button
round
block
type="primary"
text-color="#ffffff"
@click="onToTalk"
style="width: 91%"
>
<template #icon>
<n-icon :component="SendOne" />
</template>
发送消息
</n-button>
</div>
</template>
</footer>
<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>
</x-n-modal>
</n-modal>
</template>
<style lang="less" scoped>
.section {
width: 100%;
height: 100%;
position: relative;
background-image: url('@/assets/image/zu6254@2x.png');
width: 360px;
height: 600px;
border-radius: 10px;
overflow: hidden;
background-color: var(--im-bg-color);
@ -281,6 +336,7 @@ const onAfterLeave = () => {
height: 230px;
align-items: center;
justify-content: center;
background: linear-gradient(to right, rgb(137, 104, 255), rgb(175, 152, 255));
display: flex;
padding: 20px;
position: relative;
@ -290,6 +346,7 @@ const onAfterLeave = () => {
width: 150px;
height: 150px;
content: '';
background: linear-gradient(to right, rgb(142, 110, 255), rgb(208, 195, 255));
position: absolute;
z-index: 1;
border-radius: 50%;

View File

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

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

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

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