Compare commits
92 Commits
950ca2876c
...
dbdec912ce
Author | SHA1 | Date | |
---|---|---|---|
dbdec912ce | |||
d5b0a8b599 | |||
e27682badf | |||
a86cdbf94a | |||
e1e11b7633 | |||
331ca65db6 | |||
|
2c1ae41c3e | ||
bdfd604fd9 | |||
|
44a1dd0986 | ||
|
8ce7d143ce | ||
|
58b70f84d7 | ||
|
6d663d3d01 | ||
|
b117765bdc | ||
1edb639ad9 | |||
8ecee15180 | |||
|
e3f2346d66 | ||
efb410b657 | |||
|
c91a70f86d | ||
|
02ba7af6eb | ||
|
19a6c89b76 | ||
|
e2e0a3ea3a | ||
|
5bda2be585 | ||
|
57f169ca78 | ||
|
470da9e7b7 | ||
|
c7df773b97 | ||
|
b7ae8598b4 | ||
69e95e5c4d | |||
6517c082d5 | |||
|
cba7e9205e | ||
|
9487ae526b | ||
|
e3d61107cb | ||
|
db599dadb9 | ||
|
89f707a031 | ||
|
46644626e7 | ||
|
0fe1119789 | ||
|
91107e2f85 | ||
|
579fed2e69 | ||
0ab2ce814a | |||
41dbb8c872 | |||
8694921f25 | |||
|
b65f38f02e | ||
62f5b458a5 | |||
2439562838 | |||
|
df80cd031e | ||
|
f1b802cde8 | ||
|
846031a5cb | ||
cecca6df9c | |||
19e4954484 | |||
115a3f1f10 | |||
|
23415808bb | ||
|
73063d1faf | ||
|
ae23e0a1d1 | ||
|
c93023effa | ||
3eaac91ba8 | |||
9360ecaaf9 | |||
|
edec2753ba | ||
|
b5ccba9899 | ||
c39d5aea88 | |||
|
b04d25a243 | ||
|
9e31271cc3 | ||
|
6d08dbe42f | ||
|
478336c2fe | ||
|
419bde4db2 | ||
|
fca127b42b | ||
814eb44358 | |||
|
94cf0f9f63 | ||
|
fad84e5bf3 | ||
ed0737b5e3 | |||
|
661472a70a | ||
701d878f7d | |||
7544b3d324 | |||
8c9f634d0b | |||
|
651baafd0f | ||
|
c9794c3f25 | ||
|
067312cd5c | ||
a82875da05 | |||
9bd1bdadb2 | |||
|
51a406e5e5 | ||
|
fed311c76e | ||
|
7895ff81c8 | ||
|
b84430a7e3 | ||
b35243bb79 | |||
43541a1187 | |||
|
d413a6b9fe | ||
d021415568 | |||
|
d2c8de16bb | ||
|
7717fe1fb3 | ||
903ae24458 | |||
a80c52475e | |||
8549ca6b54 | |||
|
9f63dbfe27 | ||
|
c695529217 |
8
.env
@ -1,8 +0,0 @@
|
|||||||
ENV = 'development'
|
|
||||||
|
|
||||||
VITE_BASE=/
|
|
||||||
VUE_APP_PREVIEW=false
|
|
||||||
VITE_BASE_API=http://172.16.100.93:8503
|
|
||||||
VITE_EPR_BASEURL=http://114.218.158.24:9020
|
|
||||||
VITE_SOCKET_API=ws://172.16.100.93:8504
|
|
||||||
VUE_APP_WEBSITE_NAME="Lumen IM"
|
|
@ -1,6 +0,0 @@
|
|||||||
ENV = 'production'
|
|
||||||
|
|
||||||
VITE_BASE=./
|
|
||||||
VITE_ROUTER_MODE=hash
|
|
||||||
VITE_BASE_API=https://xxx.xxx.com
|
|
||||||
VITE_SOCKET_API=wss://xxx.xxx.com
|
|
@ -1,23 +0,0 @@
|
|||||||
/* eslint-env node */
|
|
||||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
env: {
|
|
||||||
node: true // 只需将该项设置为 true 即可
|
|
||||||
},
|
|
||||||
root: true,
|
|
||||||
'extends': [
|
|
||||||
'plugin:vue/vue3-essential',
|
|
||||||
'eslint:recommended',
|
|
||||||
'@vue/eslint-config-typescript',
|
|
||||||
'@vue/eslint-config-prettier/skip-formatting'
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 'latest'
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'vue/multi-word-component-names': 'off',
|
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
|
||||||
"no-unused-vars":"off"
|
|
||||||
}
|
|
||||||
}
|
|
2
.gitignore
vendored
@ -24,3 +24,5 @@ makefile
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
components.d.ts
|
||||||
|
auto-imports.d.ts
|
||||||
|
@ -5,4 +5,4 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"trailingComma": "none"
|
"trailingComma": "none"
|
||||||
}
|
}
|
143
README.md
@ -1,87 +1,104 @@
|
|||||||
# Lumen IM 即时聊天
|
# IM - 在线即时通讯应用
|
||||||
|
|
||||||
<img alt="GitHub stars badge" src="https://img.shields.io/github/stars/gzydong/LumenIM"> <img alt="GitHub forks badge" src="https://img.shields.io/github/forks/gzydong/LumenIM"> <img alt="GitHub license badge" src="https://img.shields.io/github/license/gzydong/LumenIM">
|
IM 是一个基于 Vue 3 开发的现代化在线即时通讯应用,提供实时聊天、消息管理、笔记等功能。
|
||||||
|
|
||||||
### 项目介绍
|
## 功能特性
|
||||||
|
|
||||||
Lumen IM 是一个网页版在线聊天项目,前端使用 Naive UI + Vue3,后端采用 GO 开发。
|
- 📱 实时聊天:支持一对一即时通讯
|
||||||
|
- 📝 消息管理:高效管理各类消息
|
||||||
|
- 📓 笔记功能:支持Markdown格式的笔记编辑与管理
|
||||||
|
- 🌓 暗色模式:支持明暗主题切换,呵护您的眼睛
|
||||||
|
- 🔒 用户认证:完善的登录注册系统
|
||||||
|
|
||||||
### 功能模块
|
## 技术栈
|
||||||
|
|
||||||
- 支持私聊及群聊
|
- **前端框架**:Vue 3 + TypeScript
|
||||||
- 支持多种聊天消息类型 例如:文本消息、代码块、群投票、图片及其它类型文件,并支持文件下载
|
- **状态管理**:Pinia
|
||||||
- 支持聊天消息撤回、删除(批量删除)、转发消息(逐条转发、合并转发)
|
- **UI组件库**:Naive UI
|
||||||
- 支持编写笔记
|
- **路由管理**:Vue Router
|
||||||
|
- **CSS预处理器**:Less
|
||||||
|
- **构建工具**:Vite
|
||||||
|
- **WebSocket**:用于实时通讯
|
||||||
|
- **编辑器**:
|
||||||
|
- Markdown编辑器:@kangc/v-md-editor
|
||||||
|
- 富文本编辑器:Quill
|
||||||
|
|
||||||
### 项目预览
|
## 快速开始
|
||||||
|
|
||||||
- 地址: [http://im.gzydong.com](http://im.gzydong.com)
|
### 环境要求
|
||||||
|
|
||||||
### 项目安装
|
- Node.js >= 14.0.0
|
||||||
|
- pnpm >= 6.0.0
|
||||||
|
|
||||||
###### 下载安装
|
### 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
## 克隆项目源码包
|
pnpm install
|
||||||
git clone https://gitee.com/gzydong/LumenIM.git
|
|
||||||
或
|
|
||||||
git clone https://github.com/gzydong/LumenIM.git
|
|
||||||
|
|
||||||
## 安装项目依赖扩展组件
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# 启动本地开发环境
|
|
||||||
yarn dev
|
|
||||||
# 启动本地开发环境桌面客户端
|
|
||||||
yarn electron:dev
|
|
||||||
|
|
||||||
## 生产环境构建项目
|
|
||||||
yarn build
|
|
||||||
|
|
||||||
## 生产环境桌面客户端打包
|
|
||||||
yarn electron:build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
###### 修改 .env 配置信息
|
### 开发环境运行
|
||||||
|
|
||||||
```env
|
```bash
|
||||||
VITE_BASE_API=http://127.0.0.1:8503
|
# 测试环境
|
||||||
VITE_SOCKET_API=ws://127.0.0.1:8504
|
pnpm dev:test
|
||||||
|
|
||||||
|
# 生产环境
|
||||||
|
pnpm dev:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
###### 关于 Nginx 的一些配置
|
### 打包构建
|
||||||
|
|
||||||
```nginx
|
```bash
|
||||||
server {
|
# 测试环境构建
|
||||||
listen 80;
|
pnpm build:test
|
||||||
server_name www.yourdomain.com;
|
|
||||||
|
|
||||||
root /project-path/dist;
|
# 生产环境构建
|
||||||
index index.html;
|
pnpm build:prod
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf|flv|ico)$ {
|
|
||||||
expires 7d;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ .*\.(js|css)?$ {
|
|
||||||
expires 7d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 项目源码
|
### 预览构建后的项目
|
||||||
|
|
||||||
| 代码仓库 | 前端源码 | 后端源码 |
|
```bash
|
||||||
| -------- | ---------------------------------- | ---------------------------------- |
|
pnpm preview
|
||||||
| Github | https://github.com/gzydong/LumenIM | https://github.com/gzydong/go-chat |
|
```
|
||||||
| 码云 | https://gitee.com/gzydong/LumenIM | https://gitee.com/gzydong/go-chat |
|
|
||||||
|
|
||||||
#### 联系方式
|
## 项目结构
|
||||||
|
|
||||||
QQ作者 : 837215079
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # API请求
|
||||||
|
├── assets/ # 静态资源
|
||||||
|
├── components/ # 公共组件
|
||||||
|
├── connect.ts # WebSocket连接管理
|
||||||
|
├── constant/ # 常量定义
|
||||||
|
├── directive/ # 自定义指令
|
||||||
|
├── event/ # 事件管理
|
||||||
|
├── hooks/ # 自定义钩子
|
||||||
|
├── layout/ # 布局组件
|
||||||
|
├── main.ts # 入口文件
|
||||||
|
├── plugins/ # 插件配置
|
||||||
|
├── router/ # 路由配置
|
||||||
|
├── store/ # 状态管理
|
||||||
|
├── types/ # 类型定义
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
└── views/ # 页面视图
|
||||||
|
```
|
||||||
|
|
||||||
### 如果你觉得还不错,请 Star , Fork 给作者鼓励一下。
|
## 环境变量配置
|
||||||
|
|
||||||
|
项目支持不同环境配置,环境变量文件位于`env/`目录下。
|
||||||
|
|
||||||
|
## 浏览器支持
|
||||||
|
|
||||||
|
支持现代浏览器,如Chrome、Firefox、Safari、Edge等。
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- [Vue 3](https://v3.vuejs.org/)
|
||||||
|
- [Vite](https://vitejs.dev/)
|
||||||
|
- [Naive UI](https://www.naiveui.com/)
|
||||||
|
- [Pinia](https://pinia.vuejs.org/)
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
Copyright © 2023 IM
|
||||||
|
@ -1,92 +0,0 @@
|
|||||||
// 控制应用生命周期和创建原生浏览器窗口的模组
|
|
||||||
const { app, BrowserWindow, ipcMain, Menu, MenuItem } = require('electron')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
const { shell } = require('electron')
|
|
||||||
|
|
||||||
const NODE_ENV = process.env.NODE_ENV
|
|
||||||
|
|
||||||
function loadHtmlUrl() {
|
|
||||||
return NODE_ENV === 'development'
|
|
||||||
? `http://localhost:${process.env.PROT}`
|
|
||||||
: `file://${path.join(__dirname, '../dist/index.html')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindow() {
|
|
||||||
// 创建浏览器窗口
|
|
||||||
const win = new BrowserWindow({
|
|
||||||
width: 1200,
|
|
||||||
height: 800,
|
|
||||||
minWidth: 900,
|
|
||||||
minHeight: 600,
|
|
||||||
frame: false,
|
|
||||||
titleBarStyle: 'hidden',
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
contextIsolation: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 加载 index.html
|
|
||||||
win.loadURL(loadHtmlUrl())
|
|
||||||
|
|
||||||
// 打开开发工具
|
|
||||||
if (NODE_ENV === 'development') {
|
|
||||||
win.webContents.openDevTools()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进入全屏模式
|
|
||||||
win.on('enter-full-screen', function () {
|
|
||||||
win.webContents.send('full-screen', 'enter')
|
|
||||||
})
|
|
||||||
|
|
||||||
// 退出全屏模式
|
|
||||||
win.on('leave-full-screen', function () {
|
|
||||||
win.webContents.send('full-screen', 'leave')
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on('get-full-screen', (e, data) => {
|
|
||||||
e.returnValue = win.isFullScreen()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on('app-info', (e, data) => {
|
|
||||||
e.returnValue = {
|
|
||||||
platform: process.platform,
|
|
||||||
version: app.getVersion(),
|
|
||||||
appPath: app.getAppPath(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 这段程序将会在 Electron 结束初始化
|
|
||||||
// 和创建浏览器窗口的时候调用
|
|
||||||
// 部分 API 在 ready 事件触发后才能使用。
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
createWindow()
|
|
||||||
|
|
||||||
app.on('activate', function () {
|
|
||||||
// 通常在 macOS 上,当点击 dock 中的应用程序图标时,如果没有其他
|
|
||||||
// 打开的窗口,那么程序会重新创建一个窗口。
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 除了 macOS 外,当所有窗口都被关闭的时候退出程序。 因此,通常对程序和它们在
|
|
||||||
// 任务栏上的图标来说,应当保持活跃状态,直到用户使用 Cmd + Q 退出。
|
|
||||||
app.on('window-all-closed', function () {
|
|
||||||
if (process.platform !== 'darwin') app.quit()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 在这个文件中,你可以包含应用程序剩余的所有部分的代码,
|
|
||||||
// 也可以拆分成几个文件,然后用 require 导入。
|
|
||||||
|
|
||||||
ipcMain.on('ipc:set-badge', async (event, num) => {
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
app.dock.setBadge(num > 99 ? '99+' : num)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on('ipc:open-link', async (event, link) => {
|
|
||||||
// Open a link in the default browser
|
|
||||||
shell.openExternal(link)
|
|
||||||
})
|
|
@ -1,46 +0,0 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron')
|
|
||||||
|
|
||||||
// 暴露方法给渲染进程调用
|
|
||||||
contextBridge.exposeInMainWorld('electron', {
|
|
||||||
// 设置消息未读数
|
|
||||||
setBadge: num => {
|
|
||||||
ipcRenderer.send('ipc:set-badge', num == 0 ? '' : `${num}`)
|
|
||||||
},
|
|
||||||
// 获取窗口全屏状态
|
|
||||||
getFullScreenStatus: () => {
|
|
||||||
return ipcRenderer.sendSync('get-full-screen', '')
|
|
||||||
},
|
|
||||||
// 系统信息
|
|
||||||
getAppPlatform: () => {
|
|
||||||
return ipcRenderer.sendSync('app-info', '')
|
|
||||||
},
|
|
||||||
|
|
||||||
openLink: link => {
|
|
||||||
ipcRenderer.send('ipc:open-link', link)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 窗口变化事件
|
|
||||||
ipcRenderer.on('full-screen', function (event, value) {
|
|
||||||
// isFullScreenStatus = value == 'enter'
|
|
||||||
|
|
||||||
document.dispatchEvent(
|
|
||||||
new CustomEvent('full-screen-event', { detail: value })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 触发自定义事件
|
|
||||||
// document.dispatchEvent(new CustomEvent('myTestEvent', {num: i}))
|
|
||||||
// document.addEventListener('myTestEvent', e => {console.log(e)})
|
|
||||||
// 所有Node.js API都可以在预加载过程中使用。
|
|
||||||
// 它拥有与Chrome扩展一样的沙盒。
|
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const replaceText = (selector, text) => {
|
|
||||||
const element = document.getElementById(selector)
|
|
||||||
if (element) element.innerText = text
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dependency of ['chrome', 'node', 'electron']) {
|
|
||||||
replaceText(`${dependency}-version`, process.versions[dependency])
|
|
||||||
}
|
|
||||||
})
|
|
0
.env.production → env/.env.prod
vendored
11
env/.env.test
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
ENV = 'development'
|
||||||
|
|
||||||
|
VITE_BASE=/
|
||||||
|
VUE_APP_PREVIEW=false
|
||||||
|
#VITE_BASE_API=http://192.168.88.21:9503
|
||||||
|
|
||||||
|
#VITE_SOCKET_API=ws://192.168.88.21:9504
|
||||||
|
VITE_BASE_API=http://114.218.158.24:8503
|
||||||
|
VITE_SOCKET_API=ws://114.218.158.24:8504
|
||||||
|
VITE_EPR_BASEURL=http://114.218.158.24:9020
|
||||||
|
VUE_APP_WEBSITE_NAME=""
|
@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="./src/assets/image/favicon.png" />
|
<link rel="icon" href="./src/assets/image/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Lumen IM 在线聊天</title>
|
<title> 在线聊天</title>
|
||||||
<style>
|
<style>
|
||||||
.outer,
|
.outer,
|
||||||
.middle,
|
.middle,
|
||||||
|
9309
package-lock.json
generated
Normal file
67
package.json
@ -1,33 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "LumenIM",
|
"name": "IM",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "electron/main.js",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --mode development --port 5273",
|
"dev:test": "vite --mode test --port 5273",
|
||||||
"build": "vite build",
|
"dev:prod": "vite --mode prod --port 5273",
|
||||||
|
"build:test": "vite build --mode test",
|
||||||
|
"build:prod": "vite build --mode test",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron": "wait-on tcp:5174 && cross-env NODE_ENV=development PROT=5174 electron .",
|
|
||||||
"electron:dev": "concurrently -k \"npm run dev\" \"npm run electron\"",
|
|
||||||
"electron:build": "vite build --mode electron && electron-builder --mac && electron-builder --win --x64",
|
|
||||||
"electron:build-mac": "vite build --mode electron && electron-builder --mac",
|
|
||||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
"@highlightjs/vue-plugin": "^2.1.0",
|
"@highlightjs/vue-plugin": "^2.1.0",
|
||||||
|
"@iconify-json/ion": "^1.2.3",
|
||||||
"@kangc/v-md-editor": "^2.3.18",
|
"@kangc/v-md-editor": "^2.3.18",
|
||||||
|
"@onlyoffice/document-editor-vue": "^1.5.0",
|
||||||
|
"@vicons/fluent": "^0.13.0",
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"@vueup/vue-quill": "^1.2.0",
|
"@vueup/vue-quill": "^1.2.0",
|
||||||
"@vueuse/core": "^10.7.0",
|
"@vueuse/core": "^10.7.0",
|
||||||
|
"ant-design-vue": "^4.2.6",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"highlight.js": "^11.5.0",
|
"highlight.js": "^11.5.0",
|
||||||
"js-audio-recorder": "^1.0.7",
|
"js-audio-recorder": "^1.0.7",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"pinia-plugin-persistedstate": "^3.2.0",
|
||||||
|
"pnpm": "^10.10.0",
|
||||||
"quill": "^1.3.7",
|
"quill": "^1.3.7",
|
||||||
"quill-image-uploader": "^1.3.0",
|
"quill-image-uploader": "^1.3.0",
|
||||||
"quill-mention": "^4.1.0",
|
"quill-mention": "^4.1.0",
|
||||||
|
"sortablejs": "^1.15.6",
|
||||||
|
"viewerjs": "^1.11.7",
|
||||||
"vue": "^3.3.11",
|
"vue": "^3.3.11",
|
||||||
"vue-cropper": "^1.1.1",
|
"vue-cropper": "^1.1.1",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
@ -37,44 +45,41 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@icon-park/vue-next": "^1.4.2",
|
"@icon-park/vue-next": "^1.4.2",
|
||||||
"@rushstack/eslint-patch": "^1.3.3",
|
|
||||||
"@tsconfig/node18": "^18.2.2",
|
"@tsconfig/node18": "^18.2.2",
|
||||||
"@types/node": "^18.18.5",
|
"@types/node": "^18.18.5",
|
||||||
"@types/vue": "^2.0.0",
|
"@types/vue": "^2.0.0",
|
||||||
|
"@unocss/reset": "^66.1.1",
|
||||||
"@vitejs/plugin-vue": "^4.4.0",
|
"@vitejs/plugin-vue": "^4.4.0",
|
||||||
"@vitejs/plugin-vue-jsx": "^3.0.2",
|
"@vitejs/plugin-vue-jsx": "^3.0.2",
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
|
||||||
"@vue/tsconfig": "^0.4.0",
|
"@vue/tsconfig": "^0.4.0",
|
||||||
"concurrently": "^7.3.0",
|
"concurrently": "^7.3.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"electron": "^19.1.9",
|
|
||||||
"electron-builder": "^23.6.0",
|
|
||||||
"eslint": "^8.49.0",
|
|
||||||
"eslint-config-prettier": "^9.0.0",
|
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
|
||||||
"eslint-plugin-vue": "^9.17.0",
|
|
||||||
"less": "^4.2.0",
|
"less": "^4.2.0",
|
||||||
"less-loader": "^11.1.3",
|
"less-loader": "^11.1.3",
|
||||||
"naive-ui": "^2.35.0",
|
"naive-ui": "^2.35.0",
|
||||||
"npm-run-all2": "^6.1.1",
|
"npm-run-all2": "^6.1.1",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
|
"sass": "^1.88.0",
|
||||||
"typescript": "~5.2.0",
|
"typescript": "~5.2.0",
|
||||||
"vite": "^4.5.1",
|
"unocss": "0.58.0",
|
||||||
|
"unplugin-auto-import": "^19.2.0",
|
||||||
|
"unplugin-vue-components": "^28.5.0",
|
||||||
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
|
"vite-plugin-vue-devtools": "^7.7.6",
|
||||||
"vue-tsc": "^1.8.25",
|
"vue-tsc": "^1.8.25",
|
||||||
"wait-on": "^6.0.1"
|
"wait-on": "^6.0.1"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.gzydong.lumenim",
|
"appId": "com.gzydong.im",
|
||||||
"productName": "LumenIM",
|
"productName": "IM",
|
||||||
"copyright": "Copyright © 2023 LumenIM",
|
"copyright": "Copyright © 2023 IM",
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.utilities",
|
"category": "public.app-category.utilities",
|
||||||
"icon": "build/icons/lumen-im-mac.png"
|
"icon": "build/icons/-im-mac.png"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"icon": "build/icons/lumen-im-mac.png",
|
"icon": "build/icons/-im-mac.png",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "nsis"
|
"target": "nsis"
|
||||||
@ -84,20 +89,12 @@
|
|||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"installerIcon": "build/icons/lumen-im-win.ico",
|
"installerIcon": "build/icons/-im-win.ico",
|
||||||
"uninstallerIcon": "build/icons/lumen-im-win.ico",
|
"uninstallerIcon": "build/icons/-im-win.ico",
|
||||||
"installerHeaderIcon": "build/icons/lumen-im-win.ico",
|
"installerHeaderIcon": "build/icons/-im-win.ico",
|
||||||
"createDesktopShortcut": true,
|
"createDesktopShortcut": true,
|
||||||
"createStartMenuShortcut": true,
|
"createStartMenuShortcut": true,
|
||||||
"shortcutName": "lumeim-icon"
|
"shortcutName": "lumeim-icon"
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist/**/*",
|
|
||||||
"electron/**/*"
|
|
||||||
],
|
|
||||||
"directories": {
|
|
||||||
"buildResources": "assets",
|
|
||||||
"output": "dist_electron"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4117
pnpm-lock.yaml
@ -36,7 +36,7 @@ IconProvider({
|
|||||||
strokeLinejoin: 'bevel'
|
strokeLinejoin: 'bevel'
|
||||||
})
|
})
|
||||||
|
|
||||||
const { uid: showUserId, isShow: isShowUser } = useProvideUserModal()
|
const { uid: showUserId, isShow: isShowUser,euid } = useProvideUserModal()
|
||||||
const { getDarkTheme, getThemeOverride } = useThemeMode()
|
const { getDarkTheme, getThemeOverride } = useThemeMode()
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@ -94,6 +94,7 @@ useClickEvent()
|
|||||||
<UserCardModal
|
<UserCardModal
|
||||||
v-model:show="isShowUser"
|
v-model:show="isShowUser"
|
||||||
v-model:uid="showUserId"
|
v-model:uid="showUserId"
|
||||||
|
:euid="euid"
|
||||||
@update-remark="onChangeRemark"
|
@update-remark="onChangeRemark"
|
||||||
/>
|
/>
|
||||||
</n-layout-content>
|
</n-layout-content>
|
||||||
|
@ -25,3 +25,7 @@ export const ServeRefreshToken = () => {
|
|||||||
export const ServeForgetPassword = (data) => {
|
export const ServeForgetPassword = (data) => {
|
||||||
return post('/api/v1/auth/forget', data)
|
return post('/api/v1/auth/forget', data)
|
||||||
}
|
}
|
||||||
|
// 获取用户信息服务
|
||||||
|
export const GetUserInfo = (data) => {
|
||||||
|
return post('/api/v1/users/info', data)
|
||||||
|
}
|
@ -9,7 +9,10 @@ export const ServeGetTalkList = (data = {}) => {
|
|||||||
export const ServeCreateTalkList = (data = {}) => {
|
export const ServeCreateTalkList = (data = {}) => {
|
||||||
return post('/api/v1/talk/create', data)
|
return post('/api/v1/talk/create', data)
|
||||||
}
|
}
|
||||||
|
// 聊天列表创建服务接口
|
||||||
|
export const voiceToText = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/message/voice-to-text', data)
|
||||||
|
}
|
||||||
// 删除聊天列表服务接口
|
// 删除聊天列表服务接口
|
||||||
export const ServeDeleteTalkList = (data = {}) => {
|
export const ServeDeleteTalkList = (data = {}) => {
|
||||||
return post('/api/v1/talk/delete', data)
|
return post('/api/v1/talk/delete', data)
|
||||||
@ -86,3 +89,13 @@ export const ServeSendVote = (data = {}) => {
|
|||||||
export const ServeConfirmVoteHandle = (data = {}) => {
|
export const ServeConfirmVoteHandle = (data = {}) => {
|
||||||
return post('/api/v1/talk/message/vote/handle', data)
|
return post('/api/v1/talk/message/vote/handle', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//清空聊天记录
|
||||||
|
export const ServeEmptyMessage = (data) => {
|
||||||
|
return post('/api/v1/talk/message/empty', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
//获取消息已读未读详情
|
||||||
|
export const ServeMessageReadDetail = (data) => {
|
||||||
|
return post('/api/v1/talk/my-records/read/condition', data)
|
||||||
|
}
|
||||||
|
22
src/api/components.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import _axios from '@/utils/erpRequest'
|
||||||
|
export default {
|
||||||
|
deleteDataByParams: (url, data) => _axios.fetch(url, data, 'DELETE'),
|
||||||
|
putDataByParams: (url, data) => _axios.fetch(url, data, 'PUT'),
|
||||||
|
postDataByParams: (url, data) => _axios.fetch(url, data, 'POST'),
|
||||||
|
findDates: ( data) => _axios.fetch('/report/find/dates', data, 'GET'),
|
||||||
|
postBlobByParams: (url, data) => _axios.fetch(url, data, 'POST', 'blob'),
|
||||||
|
getDataByParams: (url, data) => _axios.fetch(url, data, 'GET'),
|
||||||
|
getBlobByParams: (url, data) => _axios.fetch(url, data, 'GET', 'blob'),
|
||||||
|
uploadFormData: (url, data) => _axios.fetch(url, data, 'POST', 'json', '', true, true),
|
||||||
|
viewDetails: (data) => _axios.fetch('/health/info', data, 'POST'),
|
||||||
|
healthDelex: (data) => _axios.fetch('/health/delex', data, 'POST'),
|
||||||
|
healthDrde: (data) => _axios.fetch('/health/drde', data, 'POST'),
|
||||||
|
healthEdit: (data) => _axios.fetch('/health/edit', data, 'POST'),
|
||||||
|
healthAdddr: (data) => _axios.fetch('/health/adddr', data, 'POST'),
|
||||||
|
healthEditStreet: (data) => _axios.fetch('/health/editstreet', data, 'POST'),
|
||||||
|
healthIllmessage: (data) => _axios.fetch('/health/illmessage', data, 'POST'),
|
||||||
|
healthCall: (url, data) => _axios.fetch(url, data, 'POST'),
|
||||||
|
promotionDownload: (data) => _axios.fetch('/collections/extend', data, 'POST', 'blob'),
|
||||||
|
//只能看到我所在的组织机构树
|
||||||
|
viewMyTree: (data) => _axios.fetch('/department/v2/tree/my', data, 'POST'),
|
||||||
|
}
|
@ -45,10 +45,12 @@ export const ServeFindFriendApplyNum = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 搜索用户信息服务接口
|
// 搜索用户信息服务接口
|
||||||
|
// export const ServeSearchUser = (data) => {
|
||||||
|
// return get('/api/v1/contact/detail', data)
|
||||||
|
// }
|
||||||
export const ServeSearchUser = (data) => {
|
export const ServeSearchUser = (data) => {
|
||||||
return get('/api/v1/contact/detail', data)
|
return post('/api/v1/users/info', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索用户信息服务接口
|
// 搜索用户信息服务接口
|
||||||
export const ServeContactGroupList = (data) => {
|
export const ServeContactGroupList = (data) => {
|
||||||
return get('/api/v1/contact/group/list', data)
|
return get('/api/v1/contact/group/list', data)
|
||||||
|
@ -77,6 +77,11 @@ export const ServeEditGroupNotice = (data) => {
|
|||||||
return post('/api/v1/group/notice/edit', data)
|
return post('/api/v1/group/notice/edit', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除群公告
|
||||||
|
export const ServeDeleteGroupNotice = (data) => {
|
||||||
|
return post('/api/v1/group/notice/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
export const ServeGetGroupApplyList = (data) => {
|
export const ServeGetGroupApplyList = (data) => {
|
||||||
return get('/api/v1/group/apply/list', data)
|
return get('/api/v1/group/apply/list', data)
|
||||||
}
|
}
|
||||||
|
18
src/api/index.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// 使用 `import.meta.glob` 来同步导入所有匹配的模块
|
||||||
|
// 使用 `{ eager: true }` 选项来立即加载这些模块
|
||||||
|
const modules = import.meta.glob('./*.js', { eager: true });
|
||||||
|
|
||||||
|
const HTTP = {};
|
||||||
|
for (const path in modules) {
|
||||||
|
if (Object.hasOwnProperty.call(modules, path)) {
|
||||||
|
// 正确移除 './' 和 '.js',只保留文件名
|
||||||
|
const componentName = path.replace(/^\.\/(.*)\.\w+$/, '$1');
|
||||||
|
if (componentName !== 'index') {
|
||||||
|
// 确保我们只获取模块的默认导出
|
||||||
|
HTTP[componentName] = modules[path]?.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出 HTTP 对象
|
||||||
|
export default { HTTP };
|
36
src/api/search.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { post, get, upload } from '@/utils/request'
|
||||||
|
|
||||||
|
//ES搜索-主页搜索什么都有、指定用户、指定群、群与用户概览
|
||||||
|
export const ServeSeachQueryAll = (data = {}) => {
|
||||||
|
return post('/api/v1/elasticsearch/query-all', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ES搜索用户数据
|
||||||
|
export const ServeQueryUser = (data) => {
|
||||||
|
return post('/api/v1/elasticsearch/query-user', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ES搜索群组数据
|
||||||
|
export const ServeQueryGroup = (data) => {
|
||||||
|
return post('/api/v1/elasticsearch/query-group', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
//ES搜索聊天记录-主页搜索什么都有、聊天记录
|
||||||
|
export const ServeQueryTalkRecord = (data = {}) => {
|
||||||
|
return post('/api/v1/elasticsearch/query-talk-record', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
//查看存在聊天记录的天数
|
||||||
|
export const ServeTalkDate = (data) => {
|
||||||
|
return post('/api/v1/talk/date', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
//获取会话Id
|
||||||
|
export const ServeGetSessionId = (data) => {
|
||||||
|
return post('/api/v1/talk/session/getId', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
//获取用户所在群聊列表
|
||||||
|
export const ServeUserGroupChatList = (data) => {
|
||||||
|
return post('/api/v1/group/user/list', data)
|
||||||
|
}
|
@ -21,6 +21,9 @@ export const ServeFileSubareaUpload = (data = {}, options = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 上传图片文件或者视频
|
// 上传图片文件或者视频
|
||||||
export const uploadImg = (data) => {
|
export const uploadImg = (data, signal) => {
|
||||||
return post('/upload/img', data,{baseURL:import.meta.env.VITE_EPR_BASEURL})
|
return post('/upload/img', data, {
|
||||||
|
baseURL: import.meta.env.VITE_EPR_BASEURL,
|
||||||
|
signal: signal
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
@ -29,3 +29,8 @@ export const ServeGetUserDetail = () => {
|
|||||||
export const ServeGetUserSetting = () => {
|
export const ServeGetUserSetting = () => {
|
||||||
return get('/api/v1/users/setting')
|
return get('/api/v1/users/setting')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//根据erpUserId查询聊天系统用户详情
|
||||||
|
export const getUserInfoByERPUserId = (data) => {
|
||||||
|
return post('/api/v1/users/info', data)
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
box-sizing: border-box!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@ -15,6 +16,7 @@
|
|||||||
|
|
||||||
body,
|
body,
|
||||||
html {
|
html {
|
||||||
|
margin-right: 0!important;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 500px;
|
min-width: 500px;
|
||||||
color: #333;
|
color: #333;
|
||||||
@ -204,7 +206,7 @@ textarea {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
background-color: #dee0e3;
|
|
||||||
transform: scale(0.84);
|
transform: scale(0.84);
|
||||||
transform-origin: left;
|
transform-origin: left;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
// 默认主题
|
// 默认主题
|
||||||
html {
|
html {
|
||||||
--im-primary-color: #1890ff;
|
--im-primary-color: #462AA0;
|
||||||
--im-bg-color: #ffffff;
|
--im-bg-color: #ffffff;
|
||||||
--line-border-color: #f5f5f5;
|
--line-border-color: #f5f5f5;
|
||||||
--border-color: #eeeaea;
|
--border-color: #eeeaea;
|
||||||
--im-text-color: #333;
|
--im-text-color: #BABABA;
|
||||||
--im-text-color-grey: #333;
|
--im-text-color-grey: #333;
|
||||||
--im-active-bg-color: #f5f5f5;
|
--im-active-bg-color: #f5f5f5;
|
||||||
--im-hover-bg-color: #f5f5f5;
|
--im-hover-bg-color: #f5f5f5;
|
||||||
@ -21,15 +21,15 @@ html {
|
|||||||
// message
|
// message
|
||||||
--im-message-bg-color: #f7f7f7;
|
--im-message-bg-color: #f7f7f7;
|
||||||
--im-message-border-color: #efeff5;
|
--im-message-border-color: #efeff5;
|
||||||
--im-message-left-bg-color: #eff0f1;
|
--im-message-left-bg-color: #fff;
|
||||||
--im-message-left-text-color: #333;
|
--im-message-left-text-color: #333;
|
||||||
--im-message-right-bg-color: #daf3fd;
|
--im-message-right-bg-color: #46299D;
|
||||||
--im-message-right-text-color: #333;
|
--im-message-right-text-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 黑色主题
|
// 黑色主题
|
||||||
html[theme-mode='dark'] {
|
html[theme-mode='dark'] {
|
||||||
--im-primary-color: #1890ff;
|
--im-primary-color: #462AA0;
|
||||||
--im-bg-color: #202124;
|
--im-bg-color: #202124;
|
||||||
--line-border-color: rgb(255 255 255 / 9%);
|
--line-border-color: rgb(255 255 255 / 9%);
|
||||||
--border-color: rgb(255 255 255 / 9%);
|
--border-color: rgb(255 255 255 / 9%);
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&.dropsize-resizing {
|
&.dropsize-resizing {
|
||||||
background-color: #1890ff;
|
background-color: #462AA0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.dropsize-line-top {
|
&.dropsize-line-top {
|
||||||
|
68
src/assets/css/naive-ui-adjust.less
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/* naive ui 部分样式调整*/
|
||||||
|
/*表格排序图标颜色问题 */
|
||||||
|
.n-data-table-sorter{
|
||||||
|
color: #fff!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-checkbox-box-wrapper .n-checkbox-box{
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
/*表格头多选框颜色调整避免和表头颜色冲突*/
|
||||||
|
.n-data-table-thead .n-data-table-tr .n-checkbox-box{
|
||||||
|
background: #fff;
|
||||||
|
.n-checkbox-icon{
|
||||||
|
.check-icon{
|
||||||
|
fill:#462AA0 ;
|
||||||
|
}
|
||||||
|
svg{
|
||||||
|
fill:#462AA0 ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.n-checkbox-box__border{
|
||||||
|
border: #fff!important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*弹窗内表格背景颜色调整*/
|
||||||
|
.n-data-table .n-data-table-th {
|
||||||
|
background-color: #462AA0;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
naive ui 消息提示框 样式调整
|
||||||
|
*/
|
||||||
|
.n-message-wrapper{
|
||||||
|
.n-message{
|
||||||
|
&.n-message--info-type{
|
||||||
|
border: 1px solid #C7DFFB;
|
||||||
|
background-color: #EDF5FE;
|
||||||
|
}
|
||||||
|
&.n-message--warning-type{
|
||||||
|
border: 1px solid #FAE0B5;
|
||||||
|
background-color: #FEF7ED;
|
||||||
|
}
|
||||||
|
&.n-message--error-type{
|
||||||
|
border: 1px solid #F3CBD3;
|
||||||
|
background-color:#FBEEF1;
|
||||||
|
}
|
||||||
|
&.n-message--success-type{
|
||||||
|
border: 1px solid #C5E7D5;
|
||||||
|
background-color:#EDF7F2;
|
||||||
|
}
|
||||||
|
&.n-message--loading-type{
|
||||||
|
border: 1px solid #B2A6D6;
|
||||||
|
background-color:#EDF7F2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
n-image 图片放大查看器工具栏样式调整 样式污染问题
|
||||||
|
*/
|
||||||
|
.n-base-icon{
|
||||||
|
box-sizing: initial!important;
|
||||||
|
}
|
||||||
|
/*表格排序列背景颜色问题*/
|
||||||
|
.n-data-table .n-data-table-th.n-data-table-th--sortable{
|
||||||
|
background-color: #462AA0;
|
||||||
|
}
|
||||||
|
.n-data-table .n-data-table-th.n-data-table-th--sortable:hover{
|
||||||
|
background-color: #462AA0;
|
||||||
|
}
|
BIN
src/assets/image/chatList/addressBook.png
Normal file
After Width: | Height: | Size: 530 B |
BIN
src/assets/image/chatList/chat-settings.png
Normal file
After Width: | Height: | Size: 657 B |
BIN
src/assets/image/chatList/search-empty.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
src/assets/image/chatSettings/edit-btn.png
Normal file
After Width: | Height: | Size: 337 B |
BIN
src/assets/image/chatSettings/edit-cancel.png
Normal file
After Width: | Height: | Size: 486 B |
BIN
src/assets/image/chatSettings/edit-confirm.png
Normal file
After Width: | Height: | Size: 473 B |
BIN
src/assets/image/close.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/image/dofd.png
Normal file
After Width: | Height: | Size: 396 B |
BIN
src/assets/image/excel-text.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/image/faxi@2x.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/image/file-text.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/image/file@2x.png
Normal file
After Width: | Height: | Size: 607 B |
BIN
src/assets/image/groupCompany.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
src/assets/image/groupDepartment.png
Normal file
After Width: | Height: | Size: 5.6 KiB |
BIN
src/assets/image/groupNormal.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
src/assets/image/groupProject.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
src/assets/image/icon/arrow-right-grey.png
Normal file
After Width: | Height: | Size: 163 B |
BIN
src/assets/image/icon/close-btn-grey-line.png
Normal file
After Width: | Height: | Size: 286 B |
BIN
src/assets/image/icon/close-btn-grey.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/image/icon/search-grey.png
Normal file
After Width: | Height: | Size: 436 B |
BIN
src/assets/image/pcyyb_2100100012_installer.exe
Normal file
BIN
src/assets/image/pdf-text.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
src/assets/image/ppt-text.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/image/word-text.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
src/assets/image/xxxx@2x.png
Normal file
After Width: | Height: | Size: 684 B |
BIN
src/assets/image/zu6146@2x.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/image/zu6254@2x.png
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
src/assets/image/zu6299@2x.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
src/assets/image/zu6300@2x.png
Normal file
After Width: | Height: | Size: 4.5 KiB |
BIN
src/assets/image/zu6302@2x.png
Normal file
After Width: | Height: | Size: 4.5 KiB |
BIN
src/assets/image/zu6306@2x.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
144
src/components/avatar-module/index.vue
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="avatar-module" :style="[customStyle, { background: avatar ? '#fff' : '' }]">
|
||||||
|
<img :src="avatar" v-if="avatar" />
|
||||||
|
<span v-else :style="customTextStyle">{{ text_avatar }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="[2,3,4].includes(groupType)&&showGroupType"
|
||||||
|
class="absolute border-2px border-solid rounded-3px bg-#fff flex justify-center items-center leading-none"
|
||||||
|
:style="[
|
||||||
|
groupLabelStyle,
|
||||||
|
`color:${labelColor.find(x=>x.group_type===groupType)?.color};border-color:${labelColor.find(x=>x.group_type===groupType)?.color}`
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ labelColor.find(x=>x.group_type===groupType)?.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
//群聊默认头像
|
||||||
|
import groupNormal from '@/assets/image/groupNormal.png'
|
||||||
|
import groupDepartment from '@/assets/image/groupDepartment.png'
|
||||||
|
import groupProject from '@/assets/image/groupProject.png'
|
||||||
|
import groupCompany from '@/assets/image/groupCompany.png'
|
||||||
|
import { computed, defineProps } from 'vue'
|
||||||
|
//群类型:1=普通群;2=部门群;3=项目群;4=总群/公司群
|
||||||
|
const labelColor=[
|
||||||
|
{group_type:2,color:'#377EC6',label:'部门'},
|
||||||
|
{group_type:3,color:'#C1691C',label:'项目'},
|
||||||
|
{group_type:4,color:'#7A58DE',label:'公司'},
|
||||||
|
]
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
//模式:1=人;2=群
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
showGroupType:{
|
||||||
|
type:Boolean,
|
||||||
|
default:false
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
//头像
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
userName: {
|
||||||
|
//用户名称
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
groupType: {
|
||||||
|
//群类型:1=普通群;2=部门群;3=项目群;4=总群/公司群
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
customStyle: {
|
||||||
|
//自定义样式
|
||||||
|
type: Object,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customTextStyle: {
|
||||||
|
//自定义文字样式
|
||||||
|
type: Object,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//头像
|
||||||
|
const avatar = computed(() => {
|
||||||
|
let avatar_img = props?.avatar
|
||||||
|
if (!avatar_img) {
|
||||||
|
if (props?.mode === 1) {
|
||||||
|
} else if (props?.mode === 2) {
|
||||||
|
if (props?.groupType === 1) {
|
||||||
|
avatar_img = groupNormal
|
||||||
|
} else if (props?.groupType === 2) {
|
||||||
|
avatar_img = groupDepartment
|
||||||
|
} else if (props?.groupType === 3) {
|
||||||
|
avatar_img = groupProject
|
||||||
|
} else if (props?.groupType === 4) {
|
||||||
|
avatar_img = groupCompany
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return avatar_img
|
||||||
|
})
|
||||||
|
|
||||||
|
//文字头像
|
||||||
|
const text_avatar = computed(() => {
|
||||||
|
return props?.userName.length >= 2
|
||||||
|
? props?.userName.slice(-2)
|
||||||
|
: props?.userName
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算群标签的动态样式
|
||||||
|
const groupLabelStyle = computed(() => {
|
||||||
|
// 获取头像的宽高
|
||||||
|
const avatarWidth = parseInt(props.customStyle.width) || 42
|
||||||
|
const avatarHeight = parseInt(props.customStyle.height) || 42
|
||||||
|
|
||||||
|
// 计算标签的尺寸比例(基于原始尺寸:头像42px,标签宽32px高18px,文字10px)
|
||||||
|
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>
|
@ -44,7 +44,7 @@
|
|||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: none;
|
display: none;
|
||||||
color: #1890ff;
|
color: #462AA0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@ -177,7 +177,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
background-color: #1890ff;
|
background-color: #462AA0;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
-webkit-transform: scale(0.75);
|
-webkit-transform: scale(0.75);
|
||||||
transform: scale(0.75);
|
transform: scale(0.75);
|
||||||
|
20
src/components/common/customBtn.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<n-button
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(slot, name) in $slots"
|
||||||
|
:key="name"
|
||||||
|
#[name]
|
||||||
|
>
|
||||||
|
<slot :name="name"></slot>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { NButton } from 'naive-ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
</style>
|
156
src/components/common/customModal.vue
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<xNModal v-model:show="show" v-bind="$attrs">
|
||||||
|
<template #header>
|
||||||
|
<div class="custom-modal-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<template v-if="$slots.header">
|
||||||
|
<slot name="header"></slot>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ title }}
|
||||||
|
</template>
|
||||||
|
<div class="custom-close-btn" v-if="customCloseBtn">
|
||||||
|
<img src="@/assets/image/icon/close-btn-grey.png" alt="" @click="handleCloseModal" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<slot name="content"></slot>
|
||||||
|
<template #footer v-if="actionBtns?.cancelBtn || actionBtns?.confirmBtn">
|
||||||
|
<div
|
||||||
|
class="custom-modal-btns"
|
||||||
|
:style="props?.customModalBtnsStyle ? props.customModalBtnsStyle : ''"
|
||||||
|
>
|
||||||
|
<customBtn
|
||||||
|
color="#C7C7C9"
|
||||||
|
style="width: 161px; height: 34px;"
|
||||||
|
@click="handleCancel"
|
||||||
|
v-if="actionBtns?.cancelBtn"
|
||||||
|
>{{ actionBtns?.cancelBtn?.text || '取消' }}</customBtn
|
||||||
|
>
|
||||||
|
<customBtn
|
||||||
|
color="#46299D"
|
||||||
|
style="width: 161px; height: 34px;"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="actionBtns?.confirmBtn?.disabled"
|
||||||
|
:loading="state.confirmBtnLoading && actionBtns?.confirmBtn?.doLoading"
|
||||||
|
v-if="actionBtns?.confirmBtn"
|
||||||
|
>{{ actionBtns?.confirmBtn?.text || '确定' }}</customBtn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</xNModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, computed } from 'vue'
|
||||||
|
import xNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
|
||||||
|
import customBtn from '@/components/common/customBtn.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
// 是否显示模态框
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
// 模态框标题
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
actionBtns: {
|
||||||
|
// 操作按钮
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
customCloseBtn: {
|
||||||
|
// 是否显示自定义关闭按钮
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
customModalBtnsStyle: {
|
||||||
|
// 自定义按钮样式
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
customCloseEvent: {
|
||||||
|
// 是否自定义关闭事件
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show', 'cancel', 'confirm', 'customCloseModal'])
|
||||||
|
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (props.actionBtns?.cancelBtn?.hideModal) {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (props.actionBtns?.confirmBtn?.doLoading) {
|
||||||
|
state.confirmBtnLoading = true
|
||||||
|
}
|
||||||
|
emit('confirm', closeLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeLoading = () => {
|
||||||
|
state.confirmBtnLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
confirmBtnLoading: false // 确定按钮loading
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
if (props.customCloseEvent) {
|
||||||
|
emit('customCloseModal')
|
||||||
|
} else {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.custom-modal-header {
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
margin: 0 12px;
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
padding: 0 0 15px;
|
||||||
|
text-align: center;
|
||||||
|
color: #1f2225;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 28px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.custom-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-modal-btns {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 0 50px;
|
||||||
|
}
|
||||||
|
</style>
|
49
src/components/confirm-box/index.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import XNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
|
||||||
|
const emit = defineEmits(['cancel','confirm'])
|
||||||
|
const show=defineModel('show')
|
||||||
|
const props = defineProps({
|
||||||
|
title:{
|
||||||
|
type:String,
|
||||||
|
default:'提示'
|
||||||
|
},
|
||||||
|
content:{
|
||||||
|
type:String,
|
||||||
|
default:'内容'
|
||||||
|
},
|
||||||
|
cancelText:{
|
||||||
|
type:String,
|
||||||
|
default:'取消'
|
||||||
|
},
|
||||||
|
confirmText:{
|
||||||
|
type:String,
|
||||||
|
default:'确定'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<XNModal v-model:show="show" :closable="false" class="w-724px" content-style="padding:0px" @after-leave="emit('after-leave')">
|
||||||
|
<div class="flex flex-col w-full px-25px pb-49px">
|
||||||
|
<div class="text-20px text-#1F2225 w-full text-center border-b-1px border-b-solid border-b-#E9E9E9 py-20px">{{ title }}</div>
|
||||||
|
<div class="py-60px text-center text-20px text-#1F2225">
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full justify-center">
|
||||||
|
<n-button color="#C7C7C9" class="text-14px text-#fff w-161px h-34px mr-10px"
|
||||||
|
@click="() => { show=false; emit('cancel') }"
|
||||||
|
>{{ cancelText }}</n-button>
|
||||||
|
<n-button color="#46299D" class="text-14px text-#fff w-161px h-34px"
|
||||||
|
@click="() => { show=false; emit('confirm') }"
|
||||||
|
>{{ confirmText }}</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</XNModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
32
src/components/confirm-box/service.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { createVNode, nextTick, render } from 'vue'
|
||||||
|
import ConfirmBox from './index.vue'
|
||||||
|
|
||||||
|
export function confirmBox(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
...options,
|
||||||
|
show: false,
|
||||||
|
onCancel: () => {
|
||||||
|
reject()
|
||||||
|
|
||||||
|
},
|
||||||
|
onAfterLeave:()=>{
|
||||||
|
render(null, container)
|
||||||
|
document.body.removeChild(container)
|
||||||
|
},
|
||||||
|
onConfirm: () => {
|
||||||
|
resolve()
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const vnode = createVNode(ConfirmBox, props)
|
||||||
|
render(vnode, container)
|
||||||
|
nextTick(() => {
|
||||||
|
vnode.component.props.show = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
@ -1,81 +1,116 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
// 引入Quill编辑器的样式文件
|
||||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||||
|
// 引入图片上传插件的样式
|
||||||
import 'quill-image-uploader/dist/quill.imageUploader.min.css'
|
import 'quill-image-uploader/dist/quill.imageUploader.min.css'
|
||||||
|
// 引入自定义的提及功能样式
|
||||||
import '@/assets/css/editor-mention.less'
|
import '@/assets/css/editor-mention.less'
|
||||||
|
// 引入Vue核心功能
|
||||||
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted } from 'vue'
|
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
// 引入Naive UI的弹出框组件
|
||||||
import { NPopover } from 'naive-ui'
|
import { NPopover } from 'naive-ui'
|
||||||
|
// 引入图标组件
|
||||||
import {
|
import {
|
||||||
Voice as IconVoice,
|
Voice as IconVoice, // 语音图标
|
||||||
SourceCode,
|
SourceCode, // 代码图标
|
||||||
Local,
|
Local, // 地理位置图标
|
||||||
SmilingFace,
|
SmilingFace, // 表情图标
|
||||||
Pic,
|
Pic, // 图片图标
|
||||||
FolderUpload,
|
FolderUpload, // 文件上传图标
|
||||||
Ranking,
|
Ranking, // 排名图标(用于投票)
|
||||||
History
|
History // 历史记录图标
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
// 引入Quill编辑器及其核心实例
|
||||||
import { QuillEditor, Quill } from '@vueup/vue-quill'
|
import { QuillEditor, Quill } from '@vueup/vue-quill'
|
||||||
|
// 引入图片上传插件
|
||||||
import ImageUploader from 'quill-image-uploader'
|
import ImageUploader from 'quill-image-uploader'
|
||||||
|
// 引入自定义表情符号格式
|
||||||
import EmojiBlot from './formats/emoji'
|
import EmojiBlot from './formats/emoji'
|
||||||
|
// 引入自定义引用格式
|
||||||
import QuoteBlot from './formats/quote'
|
import QuoteBlot from './formats/quote'
|
||||||
|
// 引入提及功能
|
||||||
import 'quill-mention'
|
import 'quill-mention'
|
||||||
|
// 引入状态管理
|
||||||
import { useDialogueStore, useEditorDraftStore } from '@/store'
|
import { useDialogueStore, useEditorDraftStore } from '@/store'
|
||||||
|
// 引入编辑器工具函数
|
||||||
import { deltaToMessage, deltaToString, isEmptyDelta } from './util'
|
import { deltaToMessage, deltaToString, isEmptyDelta } from './util'
|
||||||
|
// 引入获取图片信息的工具函数
|
||||||
import { getImageInfo } from '@/utils/functions'
|
import { getImageInfo } from '@/utils/functions'
|
||||||
|
// 引入编辑器常量定义
|
||||||
import { EditorConst } from '@/constant/event-bus'
|
import { EditorConst } from '@/constant/event-bus'
|
||||||
|
// 引入事件调用工具
|
||||||
import { emitCall } from '@/utils/common'
|
import { emitCall } from '@/utils/common'
|
||||||
|
// 引入默认头像常量
|
||||||
import { defAvatar } from '@/constant/default'
|
import { defAvatar } from '@/constant/default'
|
||||||
import MeEditorVote from './MeEditorVote.vue'
|
// 引入编辑器各子组件
|
||||||
import MeEditorEmoticon from './MeEditorEmoticon.vue'
|
import MeEditorVote from './MeEditorVote.vue' // 投票组件
|
||||||
import MeEditorCode from './MeEditorCode.vue'
|
import MeEditorEmoticon from './MeEditorEmoticon.vue' // 表情组件
|
||||||
import MeEditorRecorder from './MeEditorRecorder.vue'
|
import MeEditorCode from './MeEditorCode.vue' // 代码编辑组件
|
||||||
|
import MeEditorRecorder from './MeEditorRecorder.vue' // 录音组件
|
||||||
|
// 引入上传API
|
||||||
import { ServeUploadImage } from '@/api/upload'
|
import { ServeUploadImage } from '@/api/upload'
|
||||||
import { uploadImg } from '@/api/upload'
|
import { uploadImg } from '@/api/upload'
|
||||||
|
// 引入事件总线钩子
|
||||||
import { useEventBus } from '@/hooks'
|
import { useEventBus } from '@/hooks'
|
||||||
|
// 注册Quill编辑器的自定义格式
|
||||||
|
Quill.register('formats/emoji', EmojiBlot) // 注册表情格式
|
||||||
|
Quill.register('formats/quote', QuoteBlot) // 注册引用格式
|
||||||
|
Quill.register('modules/imageUploader', ImageUploader) // 注册图片上传模块
|
||||||
|
|
||||||
Quill.register('formats/emoji', EmojiBlot)
|
// 定义组件的事件
|
||||||
Quill.register('formats/quote', QuoteBlot)
|
|
||||||
Quill.register('modules/imageUploader', ImageUploader)
|
|
||||||
|
|
||||||
const emit = defineEmits(['editor-event'])
|
const emit = defineEmits(['editor-event'])
|
||||||
|
// 获取对话状态管理
|
||||||
const dialogueStore = useDialogueStore()
|
const dialogueStore = useDialogueStore()
|
||||||
|
// 获取编辑器草稿状态管理
|
||||||
const editorDraftStore = useEditorDraftStore()
|
const editorDraftStore = useEditorDraftStore()
|
||||||
|
// 定义组件props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
vote: {
|
vote: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false // 是否显示投票功能
|
||||||
},
|
},
|
||||||
members: {
|
members: {
|
||||||
default: () => []
|
default: () => [] // 聊天成员列表,用于@功能
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 编辑器引用
|
||||||
const editor = ref()
|
const editor = ref()
|
||||||
|
|
||||||
|
// 获取Quill编辑器实例
|
||||||
const getQuill = () => {
|
const getQuill = () => {
|
||||||
return editor.value?.getQuill()
|
return editor.value?.getQuill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取当前编辑器光标位置
|
||||||
const getQuillSelectionIndex = () => {
|
const getQuillSelectionIndex = () => {
|
||||||
let quill = getQuill()
|
let quill = getQuill()
|
||||||
|
|
||||||
return (quill.getSelection() || {}).index || quill.getLength()
|
return (quill.getSelection() || {}).index || quill.getLength()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算当前对话索引名称(标识当前聊天)
|
||||||
const indexName = computed(() => dialogueStore.index_name)
|
const indexName = computed(() => dialogueStore.index_name)
|
||||||
|
// 控制是否显示编辑器的投票界面
|
||||||
const isShowEditorVote = ref(false)
|
const isShowEditorVote = ref(false)
|
||||||
|
// 控制是否显示编辑器的代码界面
|
||||||
const isShowEditorCode = ref(false)
|
const isShowEditorCode = ref(false)
|
||||||
|
// 控制是否显示录音界面
|
||||||
const isShowEditorRecorder = ref(false)
|
const isShowEditorRecorder = ref(false)
|
||||||
|
// 图片文件上传DOM引用
|
||||||
const fileImageRef = ref()
|
const fileImageRef = ref()
|
||||||
|
// 文件上传DOM引用
|
||||||
const uploadFileRef = ref()
|
const uploadFileRef = ref()
|
||||||
|
// 表情面板引用
|
||||||
const emoticonRef = ref()
|
const emoticonRef = ref()
|
||||||
|
|
||||||
|
// 编辑器配置选项
|
||||||
const editorOption = {
|
const editorOption = {
|
||||||
debug: false,
|
debug: false,
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: false,
|
toolbar: false, // 禁用默认工具栏
|
||||||
clipboard: {
|
clipboard: {
|
||||||
// 粘贴版,处理粘贴时候的自带样式
|
// 粘贴处理,去除粘贴时的自带样式
|
||||||
matchers: [[Node.ELEMENT_NODE, onClipboardMatcher]]
|
matchers: [[Node.ELEMENT_NODE, onClipboardMatcher]]
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -83,19 +118,22 @@ const editorOption = {
|
|||||||
bindings: {
|
bindings: {
|
||||||
enter: {
|
enter: {
|
||||||
key: 13,
|
key: 13,
|
||||||
handler: onSendMessage
|
handler: onSendMessage // 按Enter键发送消息
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 图片上传配置
|
||||||
imageUploader: {
|
imageUploader: {
|
||||||
upload: onEditorUpload
|
upload: onEditorUpload
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// @功能配置
|
||||||
mention: {
|
mention: {
|
||||||
allowedChars: /^[\u4e00-\u9fa5]*$/,
|
allowedChars: /^[\u4e00-\u9fa5]*$/, // 允许中文字符
|
||||||
mentionDenotationChars: ['@'],
|
mentionDenotationChars: ['@'], // @符号触发
|
||||||
positioningStrategy: 'fixed',
|
positioningStrategy: 'fixed', // 定位策略
|
||||||
|
// 渲染@项目的函数
|
||||||
renderItem: (data: any) => {
|
renderItem: (data: any) => {
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.className = 'ed-member-item'
|
el.className = 'ed-member-item'
|
||||||
@ -103,16 +141,18 @@ const editorOption = {
|
|||||||
el.innerHTML += `<span class="nickname">${data.nickname}</span>`
|
el.innerHTML += `<span class="nickname">${data.nickname}</span>`
|
||||||
return el
|
return el
|
||||||
},
|
},
|
||||||
|
// 数据源函数,过滤匹配的用户
|
||||||
source: function (searchTerm: string, renderList: any) {
|
source: function (searchTerm: string, renderList: any) {
|
||||||
|
console.log("source")
|
||||||
if (!props.members.length) {
|
if (!props.members.length) {
|
||||||
return renderList([])
|
return renderList([])
|
||||||
}
|
}
|
||||||
|
|
||||||
let list = [
|
let list = [
|
||||||
{ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' },
|
|
||||||
...props.members
|
...props.members
|
||||||
]
|
] as any
|
||||||
|
if((dialogueStore.groupInfo as any).is_manager){
|
||||||
|
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
|
||||||
|
}
|
||||||
const items = list.filter(
|
const items = list.filter(
|
||||||
(item: any) => item.nickname.toLowerCase().indexOf(searchTerm) !== -1
|
(item: any) => item.nickname.toLowerCase().indexOf(searchTerm) !== -1
|
||||||
)
|
)
|
||||||
@ -123,66 +163,73 @@ const editorOption = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
placeholder: '按Enter发送 / Shift+Enter 换行',
|
placeholder: '按Enter发送 / Shift+Enter 换行',
|
||||||
theme: 'snow'
|
theme: 'snow' // 使用snow主题
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 底部工具栏配置
|
||||||
const navs = reactive([
|
const navs = reactive([
|
||||||
{
|
{
|
||||||
title: '图片',
|
title: '图片',
|
||||||
icon: markRaw(Pic),
|
icon: markRaw(Pic),
|
||||||
show: true,
|
show: true,
|
||||||
click: () => {
|
click: () => {
|
||||||
fileImageRef.value.click()
|
fileImageRef.value.click() // 触发图片上传
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '附件',
|
title: '文件',
|
||||||
icon: markRaw(FolderUpload),
|
icon: markRaw(FolderUpload),
|
||||||
show: true,
|
show: true,
|
||||||
click: () => {
|
click: () => {
|
||||||
uploadFileRef.value.click()
|
uploadFileRef.value.click() // 触发文件上传
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
// 以下功能已被注释掉,但保留代码
|
||||||
title: '代码',
|
// {
|
||||||
icon: markRaw(SourceCode),
|
// title: '代码',
|
||||||
show: true,
|
// icon: markRaw(SourceCode),
|
||||||
click: () => {
|
// show: true,
|
||||||
isShowEditorCode.value = true
|
// click: () => {
|
||||||
}
|
// isShowEditorCode.value = true
|
||||||
},
|
// }
|
||||||
{
|
// },
|
||||||
title: '语音消息',
|
// {
|
||||||
icon: markRaw(IconVoice),
|
// title: '语音消息',
|
||||||
show: true,
|
// icon: markRaw(IconVoice),
|
||||||
click: () => {
|
// show: true,
|
||||||
isShowEditorRecorder.value = true
|
// click: () => {
|
||||||
}
|
// isShowEditorRecorder.value = true
|
||||||
},
|
// }
|
||||||
{
|
// },
|
||||||
title: '地理位置',
|
// {
|
||||||
icon: markRaw(Local),
|
// title: '地理位置',
|
||||||
show: true,
|
// icon: markRaw(Local),
|
||||||
click: () => {}
|
// show: true,
|
||||||
},
|
// click: () => {}
|
||||||
{
|
// },
|
||||||
title: '群投票',
|
// {
|
||||||
icon: markRaw(Ranking),
|
// title: '群投票',
|
||||||
show: computed(() => props.vote),
|
// icon: markRaw(Ranking),
|
||||||
click: () => {
|
// show: computed(() => props.vote),
|
||||||
isShowEditorVote.value = true
|
// click: () => {
|
||||||
}
|
// isShowEditorVote.value = true
|
||||||
},
|
// }
|
||||||
{
|
// },
|
||||||
title: '历史记录',
|
// {
|
||||||
icon: markRaw(History),
|
// title: '历史记录',
|
||||||
show: true,
|
// icon: markRaw(History),
|
||||||
click: () => {
|
// show: true,
|
||||||
emit('editor-event', emitCall('history_event'))
|
// click: () => {
|
||||||
}
|
// emit('editor-event', emitCall('history_event'))
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传图片函数
|
||||||
|
* @param file 文件对象
|
||||||
|
* @returns Promise,成功时返回图片URL
|
||||||
|
*/
|
||||||
function onUploadImage(file: File) {
|
function onUploadImage(file: File) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let image = new Image()
|
let image = new Image()
|
||||||
@ -190,35 +237,44 @@ function onUploadImage(file: File) {
|
|||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
form.append("source", "fonchain-chat");
|
form.append("source", "fonchain-chat"); // 图片来源标识
|
||||||
// form.append('width', image.width.toString())
|
// 添加图片尺寸信息作为URL参数
|
||||||
// form.append('height', image.height.toString())
|
|
||||||
form.append("urlParam", `width=${image.width}&height=${image.height}`);
|
form.append("urlParam", `width=${image.width}&height=${image.height}`);
|
||||||
|
|
||||||
|
// 调用上传API
|
||||||
uploadImg(form).then(({ code, data, message }) => {
|
uploadImg(form).then(({ code, data, message }) => {
|
||||||
if (code == 0) {
|
if (code == 0) {
|
||||||
resolve(data.ori_url)
|
resolve(data.ori_url) // 返回原始图片URL
|
||||||
} else {
|
} else {
|
||||||
resolve('')
|
resolve('')
|
||||||
window['$message'].error(message)
|
window['$message'].error(message) // 显示错误信息
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器上传处理函数
|
||||||
|
* @param file 要上传的文件
|
||||||
|
* @returns Promise
|
||||||
|
*/
|
||||||
function onEditorUpload(file: File) {
|
function onEditorUpload(file: File) {
|
||||||
async function fn(file: File, resolve: Function, reject: Function) {
|
async function fn(file: File, resolve: Function, reject: Function) {
|
||||||
if (file.type.indexOf('image/') === 0) {
|
if (file.type.indexOf('image/') === 0) {
|
||||||
|
// 如果是图片,使用图片上传处理
|
||||||
return resolve(await onUploadImage(file))
|
return resolve(await onUploadImage(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
reject()
|
reject()
|
||||||
|
|
||||||
|
// 非图片文件的处理
|
||||||
if (file.type.indexOf('video/') === 0) {
|
if (file.type.indexOf('video/') === 0) {
|
||||||
|
// 视频文件
|
||||||
let fn = emitCall('video_event', file, () => {})
|
let fn = emitCall('video_event', file, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
} else {
|
} else {
|
||||||
|
// 其他文件
|
||||||
let fn = emitCall('file_event', file, () => {})
|
let fn = emitCall('file_event', file, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
}
|
}
|
||||||
@ -229,29 +285,40 @@ function onEditorUpload(file: File) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投票事件处理
|
||||||
|
* @param data 投票数据
|
||||||
|
*/
|
||||||
function onVoteEvent(data: any) {
|
function onVoteEvent(data: any) {
|
||||||
const msg = emitCall('vote_event', data, (ok: boolean) => {
|
const msg = emitCall('vote_event', data, (ok: boolean) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
isShowEditorVote.value = false
|
isShowEditorVote.value = false // 成功后关闭投票界面
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('editor-event', msg)
|
emit('editor-event', msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表情事件处理
|
||||||
|
* @param data 表情数据
|
||||||
|
*/
|
||||||
function onEmoticonEvent(data: any) {
|
function onEmoticonEvent(data: any) {
|
||||||
emoticonRef.value.setShow(false)
|
emoticonRef.value.setShow(false) // 关闭表情面板
|
||||||
|
|
||||||
if (data.type == 1) {
|
if (data.type == 1) {
|
||||||
|
// 插入文本表情
|
||||||
const quill = getQuill()
|
const quill = getQuill()
|
||||||
let index = getQuillSelectionIndex()
|
let index = getQuillSelectionIndex()
|
||||||
|
|
||||||
|
// 删除编辑器中多余的换行符
|
||||||
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
|
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
|
||||||
quill.deleteText(0, 1)
|
quill.deleteText(0, 1)
|
||||||
index = 0
|
index = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.img) {
|
if (data.img) {
|
||||||
|
// 插入图片表情
|
||||||
quill.insertEmbed(index, 'emoji', {
|
quill.insertEmbed(index, 'emoji', {
|
||||||
alt: data.value,
|
alt: data.value,
|
||||||
src: data.img,
|
src: data.img,
|
||||||
@ -259,40 +326,54 @@ function onEmoticonEvent(data: any) {
|
|||||||
height: '24px'
|
height: '24px'
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
// 插入文本表情
|
||||||
quill.insertText(index, data.value)
|
quill.insertText(index, data.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置光标位置
|
||||||
quill.setSelection(index + 1, 0, 'user')
|
quill.setSelection(index + 1, 0, 'user')
|
||||||
} else {
|
} else {
|
||||||
|
// 发送整个表情包
|
||||||
let fn = emitCall('emoticon_event', data.value, () => {})
|
let fn = emitCall('emoticon_event', data.value, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代码事件处理
|
||||||
|
* @param data 代码数据
|
||||||
|
*/
|
||||||
function onCodeEvent(data: any) {
|
function onCodeEvent(data: any) {
|
||||||
const msg = emitCall('code_event', data, (ok: boolean) => {
|
const msg = emitCall('code_event', data, (ok: boolean) => {
|
||||||
isShowEditorCode.value = false
|
isShowEditorCode.value = false // 成功后关闭代码界面
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('editor-event', msg)
|
emit('editor-event', msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传处理
|
||||||
|
* @param e 上传事件对象
|
||||||
|
*/
|
||||||
async function onUploadFile(e: any) {
|
async function onUploadFile(e: any) {
|
||||||
let file = e.target.files[0]
|
let file = e.target.files[0]
|
||||||
|
|
||||||
e.target.value = null
|
e.target.value = null // 清空input,允许再次选择相同文件
|
||||||
|
|
||||||
console.log("文件类型"+file.type)
|
console.log("文件类型"+file.type)
|
||||||
if (file.type.indexOf('image/') === 0) {
|
if (file.type.indexOf('image/') === 0) {
|
||||||
console.log("进入图片")
|
console.log("进入图片")
|
||||||
|
// 处理图片文件
|
||||||
const quill = getQuill()
|
const quill = getQuill()
|
||||||
let index = getQuillSelectionIndex()
|
let index = getQuillSelectionIndex()
|
||||||
|
|
||||||
|
// 删除编辑器中多余的换行符
|
||||||
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
|
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
|
||||||
quill.deleteText(0, 1)
|
quill.deleteText(0, 1)
|
||||||
index = 0
|
index = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上传图片并插入到编辑器中
|
||||||
let src = await onUploadImage(file)
|
let src = await onUploadImage(file)
|
||||||
if (src) {
|
if (src) {
|
||||||
quill.insertEmbed(index, 'image', src)
|
quill.insertEmbed(index, 'image', src)
|
||||||
@ -304,29 +385,41 @@ async function onUploadFile(e: any) {
|
|||||||
|
|
||||||
if (file.type.indexOf('video/') === 0) {
|
if (file.type.indexOf('video/') === 0) {
|
||||||
console.log("进入视频")
|
console.log("进入视频")
|
||||||
|
// 处理视频文件
|
||||||
let fn = emitCall('video_event', file, () => {})
|
let fn = emitCall('video_event', file, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
} else {
|
} else {
|
||||||
console.log("进入其他")
|
console.log("进入其他")
|
||||||
|
// 处理其他类型文件
|
||||||
let fn = emitCall('file_event', file, () => {})
|
let fn = emitCall('file_event', file, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 录音事件处理
|
||||||
|
* @param file 录音文件
|
||||||
|
*/
|
||||||
function onRecorderEvent(file: any) {
|
function onRecorderEvent(file: any) {
|
||||||
emit('editor-event', emitCall('file_event', file))
|
emit('editor-event', emitCall('file_event', file))
|
||||||
isShowEditorRecorder.value = false
|
isShowEditorRecorder.value = false // 关闭录音界面
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 粘贴内容处理,移除粘贴内容中的样式
|
||||||
|
* @param node DOM节点
|
||||||
|
* @param Delta Quill Delta对象
|
||||||
|
* @returns 处理后的Delta
|
||||||
|
*/
|
||||||
function onClipboardMatcher(node: any, Delta) {
|
function onClipboardMatcher(node: any, Delta) {
|
||||||
const ops: any[] = []
|
const ops: any[] = []
|
||||||
|
|
||||||
Delta.ops.forEach((op) => {
|
Delta.ops.forEach((op) => {
|
||||||
// 如果粘贴了图片,这里会是一个对象,所以可以这样处理
|
// 处理粘贴内容
|
||||||
if (op.insert && typeof op.insert === 'string') {
|
if (op.insert && typeof op.insert === 'string') {
|
||||||
ops.push({
|
ops.push({
|
||||||
insert: op.insert, // 文字内容
|
insert: op.insert, // 文字内容
|
||||||
attributes: {} //文字样式(包括背景色和文字颜色等)
|
attributes: {} // 移除所有样式
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
ops.push(op)
|
ops.push(op)
|
||||||
@ -337,12 +430,16 @@ function onClipboardMatcher(node: any, Delta) {
|
|||||||
return Delta
|
return Delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息处理
|
||||||
|
* 根据编辑器内容类型发送不同类型的消息
|
||||||
|
*/
|
||||||
function onSendMessage() {
|
function onSendMessage() {
|
||||||
var delta = getQuill().getContents()
|
var delta = getQuill().getContents()
|
||||||
let data = deltaToMessage(delta)
|
let data = deltaToMessage(delta) // 转换Delta为消息格式
|
||||||
|
|
||||||
if (data.items.length === 0) {
|
if (data.items.length === 0) {
|
||||||
return
|
return // 没有内容不发送
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (data.msgType) {
|
switch (data.msgType) {
|
||||||
@ -351,60 +448,72 @@ function onSendMessage() {
|
|||||||
return window['$message'].info('发送内容超长,请分条发送')
|
return window['$message'].info('发送内容超长,请分条发送')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送文本消息
|
||||||
emit(
|
emit(
|
||||||
'editor-event',
|
'editor-event',
|
||||||
emitCall('text_event', data, (ok: any) => {
|
emitCall('text_event', data, (ok: any) => {
|
||||||
ok && getQuill().setContents([], Quill.sources.USER)
|
ok && getQuill().setContents([], Quill.sources.USER) // 成功发送后清空编辑器
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 3: // 图片消息
|
case 3: // 图片消息
|
||||||
|
// 发送图片消息
|
||||||
emit(
|
emit(
|
||||||
'editor-event',
|
'editor-event',
|
||||||
emitCall(
|
emitCall(
|
||||||
'image_event',
|
'image_event',
|
||||||
{ ...getImageInfo(data.items[0].content), url: data.items[0].content, size: 10000 },
|
{ ...getImageInfo(data.items[0].content), url: data.items[0].content, size: 10000 },
|
||||||
(ok: any) => {
|
(ok: any) => {
|
||||||
ok && getQuill().setContents([])
|
ok && getQuill().setContents([]) // 成功发送后清空编辑器
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 12: // 图文消息
|
case 12: // 图文混合消息
|
||||||
|
// 发送混合消息
|
||||||
emit(
|
emit(
|
||||||
'editor-event',
|
'editor-event',
|
||||||
emitCall('mixed_event', data, (ok: any) => {
|
emitCall('mixed_event', data, (ok: any) => {
|
||||||
ok && getQuill().setContents([])
|
ok && getQuill().setContents([]) // 成功发送后清空编辑器
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器内容改变时的处理
|
||||||
|
* 保存草稿并触发输入事件
|
||||||
|
*/
|
||||||
function onEditorChange() {
|
function onEditorChange() {
|
||||||
let delta = getQuill().getContents()
|
let delta = getQuill().getContents()
|
||||||
|
let text = deltaToString(delta) // 将Delta转为纯文本
|
||||||
let text = deltaToString(delta)
|
|
||||||
|
|
||||||
if (!isEmptyDelta(delta)) {
|
if (!isEmptyDelta(delta)) {
|
||||||
|
// 保存草稿到store
|
||||||
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
|
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
|
||||||
text: text,
|
text: text,
|
||||||
ops: delta.ops
|
ops: delta.ops
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 删除 editorDraftStore.items 下的元素
|
// 编辑器为空时删除对应草稿
|
||||||
delete editorDraftStore.items[indexName.value || '']
|
delete editorDraftStore.items[indexName.value || '']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发输入事件
|
||||||
emit('editor-event', emitCall('input_event', text))
|
emit('editor-event', emitCall('input_event', text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载编辑器草稿内容
|
||||||
|
* 当切换聊天对象时,加载对应的草稿
|
||||||
|
*/
|
||||||
function loadEditorDraftText() {
|
function loadEditorDraftText() {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
// 这里延迟处理,不然会有问题
|
// 延迟处理,确保DOM已渲染
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
hideMentionDom()
|
hideMentionDom() // 隐藏@菜单
|
||||||
|
|
||||||
const quill = getQuill()
|
const quill = getQuill()
|
||||||
|
|
||||||
@ -415,33 +524,47 @@ function loadEditorDraftText() {
|
|||||||
if (draft) {
|
if (draft) {
|
||||||
quill.setContents(JSON.parse(draft)?.ops || [])
|
quill.setContents(JSON.parse(draft)?.ops || [])
|
||||||
} else {
|
} else {
|
||||||
quill.setContents([])
|
quill.setContents([]) // 没有草稿则清空编辑器
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置光标位置到末尾
|
||||||
const index = getQuillSelectionIndex()
|
const index = getQuillSelectionIndex()
|
||||||
quill.setSelection(index, 0, 'user')
|
quill.setSelection(index, 0, 'user')
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理@成员事件
|
||||||
|
* @param data @成员数据
|
||||||
|
*/
|
||||||
function onSubscribeMention(data: any) {
|
function onSubscribeMention(data: any) {
|
||||||
const mention = getQuill().getModule('mention')
|
const mention = getQuill().getModule('mention')
|
||||||
|
// 插入@项
|
||||||
mention.insertItem({ id: data?.id, denotationChar: '@', value: data.value }, true)
|
mention.insertItem({ id: data?.id, denotationChar: '@', value: data.value }, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理引用事件
|
||||||
|
* @param data 引用数据
|
||||||
|
*/
|
||||||
function onSubscribeQuote(data: any) {
|
function onSubscribeQuote(data: any) {
|
||||||
|
// 检查是否已有引用内容
|
||||||
const delta = getQuill().getContents()
|
const delta = getQuill().getContents()
|
||||||
if (delta.ops?.some((item: any) => item.insert.quote)) {
|
if (delta.ops?.some((item: any) => item.insert.quote)) {
|
||||||
return
|
return // 已有引用则不再添加
|
||||||
}
|
}
|
||||||
|
|
||||||
const quill = getQuill()
|
const quill = getQuill()
|
||||||
const index = getQuillSelectionIndex()
|
const index = getQuillSelectionIndex()
|
||||||
|
|
||||||
|
// 在编辑器开头插入引用
|
||||||
quill.insertEmbed(0, 'quote', data)
|
quill.insertEmbed(0, 'quote', data)
|
||||||
quill.setSelection(index + 1, 0, 'user')
|
quill.setSelection(index + 1, 0, 'user') // 设置光标到引用后
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏@成员DOM元素
|
||||||
|
*/
|
||||||
function hideMentionDom() {
|
function hideMentionDom() {
|
||||||
let el = document.querySelector('.ql-mention-list-container')
|
let el = document.querySelector('.ql-mention-list-container')
|
||||||
if (el) {
|
if (el) {
|
||||||
@ -449,27 +572,54 @@ function hideMentionDom() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理编辑消息事件
|
||||||
|
* @param data 消息数据
|
||||||
|
*/
|
||||||
|
function onSubscribeEdit(data: any) {
|
||||||
|
const quill = getQuill()
|
||||||
|
if (!quill) return
|
||||||
|
|
||||||
|
// 清空当前编辑器内容
|
||||||
|
quill.setContents([])
|
||||||
|
|
||||||
|
// 插入要编辑的文本内容
|
||||||
|
quill.setText(data.content)
|
||||||
|
|
||||||
|
// 设置光标位置到末尾
|
||||||
|
const index = quill.getLength() - 1
|
||||||
|
quill.setSelection(index > 0 ? index : 0, 0, 'user')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听聊天索引变化,切换聊天时加载对应草稿
|
||||||
watch(indexName, loadEditorDraftText, { immediate: true })
|
watch(indexName, loadEditorDraftText, { immediate: true })
|
||||||
|
|
||||||
|
// 组件挂载时初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadEditorDraftText()
|
loadEditorDraftText()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 组件卸载时清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
hideMentionDom()
|
hideMentionDom()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 订阅编辑器相关事件总线事件
|
||||||
useEventBus([
|
useEventBus([
|
||||||
{ name: EditorConst.Mention, event: onSubscribeMention },
|
{ name: EditorConst.Mention, event: onSubscribeMention }, // @成员事件
|
||||||
{ name: EditorConst.Quote, event: onSubscribeQuote }
|
{ name: EditorConst.Quote, event: onSubscribeQuote }, // 引用事件
|
||||||
|
{ name: EditorConst.Edit, event: onSubscribeEdit } // 编辑消息事件
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 编辑器容器 -->
|
||||||
<section class="el-container editor">
|
<section class="el-container editor">
|
||||||
<section class="el-container is-vertical">
|
<section class="el-container is-vertical">
|
||||||
|
<!-- 工具栏区域 -->
|
||||||
<header class="el-header toolbar bdr-t">
|
<header class="el-header toolbar bdr-t">
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
|
<!-- 表情选择器弹出框 -->
|
||||||
<n-popover
|
<n-popover
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
@ -489,6 +639,7 @@ useEventBus([
|
|||||||
<MeEditorEmoticon @on-select="onEmoticonEvent" />
|
<MeEditorEmoticon @on-select="onEmoticonEvent" />
|
||||||
</n-popover>
|
</n-popover>
|
||||||
|
|
||||||
|
<!-- 工具栏其他功能按钮 -->
|
||||||
<div
|
<div
|
||||||
class="item pointer"
|
class="item pointer"
|
||||||
v-for="nav in navs"
|
v-for="nav in navs"
|
||||||
@ -502,6 +653,7 @@ useEventBus([
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 编辑器主体区域 -->
|
||||||
<main class="el-main height100">
|
<main class="el-main height100">
|
||||||
<QuillEditor
|
<QuillEditor
|
||||||
ref="editor"
|
ref="editor"
|
||||||
@ -514,11 +666,13 @@ useEventBus([
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 隐藏的文件上传表单 -->
|
||||||
<form enctype="multipart/form-data" style="display: none">
|
<form enctype="multipart/form-data" style="display: none">
|
||||||
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" />
|
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" />
|
||||||
<input type="file" ref="uploadFileRef" @change="onUploadFile" />
|
<input type="file" ref="uploadFileRef" @change="onUploadFile" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- 条件渲染的功能组件 -->
|
||||||
<MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" />
|
<MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" />
|
||||||
|
|
||||||
<MeEditorCode
|
<MeEditorCode
|
||||||
@ -536,7 +690,7 @@ useEventBus([
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.editor {
|
.editor {
|
||||||
--tip-bg-color: rgb(241 241 241 / 90%);
|
--tip-bg-color: rgb(241 241 241 / 90%); /* 提示背景颜色 */
|
||||||
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
@ -559,7 +713,7 @@ useEventBus([
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
.tip-title {
|
.tip-title {
|
||||||
display: none;
|
display: none; /* 默认隐藏提示文字 */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 40px;
|
top: 40px;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
@ -577,7 +731,7 @@ useEventBus([
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.tip-title {
|
.tip-title {
|
||||||
display: block;
|
display: block; /* 悬停时显示提示文字 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -585,6 +739,7 @@ useEventBus([
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 暗色模式样式调整 */
|
||||||
html[theme-mode='dark'] {
|
html[theme-mode='dark'] {
|
||||||
.editor {
|
.editor {
|
||||||
--tip-bg-color: #48484d;
|
--tip-bg-color: #48484d;
|
||||||
@ -593,13 +748,16 @@ html[theme-mode='dark'] {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
|
/* 全局编辑器样式 */
|
||||||
#editor {
|
#editor {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 编辑器主体区域样式 */
|
||||||
.ql-editor {
|
.ql-editor {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
@ -611,6 +769,7 @@ html[theme-mode='dark'] {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 悬停时显示滚动条 */
|
||||||
&:hover {
|
&:hover {
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--im-scrollbar-thumb);
|
background-color: var(--im-scrollbar-thumb);
|
||||||
@ -618,6 +777,7 @@ html[theme-mode='dark'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 编辑器占位符样式 */
|
||||||
.ql-editor.ql-blank::before {
|
.ql-editor.ql-blank::before {
|
||||||
font-family:
|
font-family:
|
||||||
PingFang SC,
|
PingFang SC,
|
||||||
@ -626,6 +786,7 @@ html[theme-mode='dark'] {
|
|||||||
left: 8px;
|
left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 编辑器中图片样式 */
|
||||||
.ql-snow .ql-editor img {
|
.ql-snow .ql-editor img {
|
||||||
max-width: 100px;
|
max-width: 100px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@ -633,6 +794,7 @@ html[theme-mode='dark'] {
|
|||||||
margin: 0px 2px;
|
margin: 0px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 图片上传中样式 */
|
||||||
.image-uploading {
|
.image-uploading {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
@ -646,15 +808,18 @@ html[theme-mode='dark'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表情符号样式 */
|
||||||
.ed-emoji {
|
.ed-emoji {
|
||||||
background-color: unset !important;
|
background-color: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 编辑器占位符样式 */
|
||||||
.ql-editor.ql-blank::before {
|
.ql-editor.ql-blank::before {
|
||||||
font-style: unset;
|
font-style: unset;
|
||||||
color: #b8b3b3;
|
color: #b8b3b3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 引用卡片样式 */
|
||||||
.quote-card-content {
|
.quote-card-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
background-color: #f6f6f6;
|
background-color: #f6f6f6;
|
||||||
@ -691,6 +856,7 @@ html[theme-mode='dark'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 暗色模式下的样式调整 */
|
||||||
html[theme-mode='dark'] {
|
html[theme-mode='dark'] {
|
||||||
.ql-editor.ql-blank::before {
|
.ql-editor.ql-blank::before {
|
||||||
color: #57575a;
|
color: #57575a;
|
||||||
|
@ -48,10 +48,10 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
|
|||||||
<input type="file" ref="fileImageRef" accept="image/*" @change="onUpload" />
|
<input type="file" ref="fileImageRef" accept="image/*" @change="onUpload" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="el-container is-vertical section height100">
|
<section class="el-container is-vertical section height100 p-10px">
|
||||||
<header class="el-header em-header bdr-b">
|
<!-- <header class="el-header em-header bdr-b">
|
||||||
<span>{{ items[tabIndex].name }}</span>
|
<span>{{ items[tabIndex].name }}</span>
|
||||||
</header>
|
</header> -->
|
||||||
|
|
||||||
<main class="el-main em-main me-scrollbar me-scrollbar-thumb">
|
<main class="el-main em-main me-scrollbar me-scrollbar-thumb">
|
||||||
<div class="symbol-box" v-if="tabIndex == 0">
|
<div class="symbol-box" v-if="tabIndex == 0">
|
||||||
@ -82,7 +82,7 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="el-footer em-footer tabs">
|
<!-- <footer class="el-footer em-footer tabs">
|
||||||
<div
|
<div
|
||||||
class="tab pointer"
|
class="tab pointer"
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
@ -93,7 +93,7 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
|
|||||||
<p class="tip">{{ item.name }}</p>
|
<p class="tip">{{ item.name }}</p>
|
||||||
<img width="20" height="20" :src="item.icon" />
|
<img width="20" height="20" :src="item.icon" />
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer> -->
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@ -185,17 +185,18 @@ const onSendEmoticon = (type: any, value: any, img = '') => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.option {
|
.option{
|
||||||
height: 32px;
|
margin: 7px;
|
||||||
width: 32px;
|
:deep(.emoji){
|
||||||
margin: 2px;
|
height: 22px;
|
||||||
font-size: 24px;
|
width: 22px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.5s;
|
transition: all 0.5s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scale(1.5);
|
transform: scale(1.5);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
121
src/components/flnlayout/tree/flnindex.vue
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fl-tree width-100 fl-mt-md">
|
||||||
|
<n-tree v-if="state.treeLoading"
|
||||||
|
block-line
|
||||||
|
:default-expanded-keys="state.expandedKeys"
|
||||||
|
:default-selected-keys="state.clickKey"
|
||||||
|
label-field="name"
|
||||||
|
key-field="key"
|
||||||
|
:expand-on-click="true"
|
||||||
|
:render-label="renderLabel"
|
||||||
|
:data="state.treeData"
|
||||||
|
@update:selected-keys="handleSelectTree" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
reactive,
|
||||||
|
onBeforeMount,
|
||||||
|
onMounted,
|
||||||
|
getCurrentInstance,
|
||||||
|
computed,
|
||||||
|
defineEmits,
|
||||||
|
watch,
|
||||||
|
nextTick,
|
||||||
|
h
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
import { PlusCircleOutlined, MinusCircleOutlined, EditOutlined, PlusOutlined, MinusOutlined, CloseOutlined, CheckOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import treeLabel from "./treelabel.vue";
|
||||||
|
import { NTree } from 'naive-ui';
|
||||||
|
|
||||||
|
const currentInstance = getCurrentInstance();
|
||||||
|
const { $request } = currentInstance.appContext.config.globalProperties;
|
||||||
|
let props = defineProps({
|
||||||
|
data: Object,
|
||||||
|
refreshCount: Number,
|
||||||
|
config: Object,
|
||||||
|
expandedKeys: Array,
|
||||||
|
clickKey: [String, Number]
|
||||||
|
})
|
||||||
|
const state = reactive({
|
||||||
|
expandedKeys: [],
|
||||||
|
editTitle: '',
|
||||||
|
treeData: [],
|
||||||
|
clickKey: [],
|
||||||
|
treeLoading: true
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.refreshCount, () => {
|
||||||
|
state.clickKey = [props.clickKey]
|
||||||
|
state.treeLoading = false
|
||||||
|
nextTick(() => {
|
||||||
|
state.treeData = props.data
|
||||||
|
calcDefaultConfig(state.treeData, 1)
|
||||||
|
state.treeLoading = true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.expandedKeys, () => {
|
||||||
|
state.clickKey = [props.clickKey]
|
||||||
|
state.expandedKeys = props.expandedKeys
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
state.clickKey = [props.clickKey]
|
||||||
|
state.treeData = props.data
|
||||||
|
calcDefaultConfig(state.treeData, 1);
|
||||||
|
state.expandedKeys = state.treeData.map(item => item.key)
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["triggerTreeAction", "triggerTreeClick", "triggerTreeDefaultClick"]);
|
||||||
|
const handleSelectTree = (keys, option, meta) => {
|
||||||
|
if (keys.length === 1) {
|
||||||
|
emit('triggerTreeClick', { selectedKey: keys[0], tree: option[0] })
|
||||||
|
} else {
|
||||||
|
emit('triggerTreeDefaultClick')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const renderLabel = (option, checked) => {
|
||||||
|
return h(
|
||||||
|
treeLabel,
|
||||||
|
{
|
||||||
|
dataRef: option,
|
||||||
|
checked: checked,
|
||||||
|
config: props.config,
|
||||||
|
clickKey: props.clickKey,
|
||||||
|
onTriggerTreeAction: handleTreeAction
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcDefaultConfig = (data, level) => {
|
||||||
|
for (let item of data) {
|
||||||
|
if (!item.key) {
|
||||||
|
item.key = item.title + '_' + level;
|
||||||
|
}
|
||||||
|
item.edit = false
|
||||||
|
if (item.children) {
|
||||||
|
calcDefaultConfig(item.children, level + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const override = ({ option }) => {
|
||||||
|
if (option.children) {
|
||||||
|
return "toggleExpand";
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
};
|
||||||
|
const handleTreeAction = ({ type, val }) => {
|
||||||
|
emit('triggerTreeAction', { type, val })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
121
src/components/flnlayout/tree/treelabel.vue
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<div class="row items-center">
|
||||||
|
<div v-if="state.treeData.edit">
|
||||||
|
<n-input v-model:value="state.editTitle"
|
||||||
|
style="max-width:200px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-popover trigger="hover"
|
||||||
|
v-else>
|
||||||
|
<template #trigger>
|
||||||
|
<div style="max-width:200px"
|
||||||
|
class="fl-px-sm sf-text-ellipsis">{{ state.treeData.title + '(' + state.treeData.staffNum + ')' }}</div>
|
||||||
|
</template>
|
||||||
|
<div>{{ state.treeData.title }}</div>
|
||||||
|
</n-popover>
|
||||||
|
<n-icon :component="CreateOutline"
|
||||||
|
class="fl-ml-sm"
|
||||||
|
size="20"
|
||||||
|
v-if="config?.actions.includes('edit')&&!state.treeData.edit"
|
||||||
|
@click.stop="handleTreeEdit(state.treeData)" />
|
||||||
|
<n-icon :component="Remove"
|
||||||
|
size="20"
|
||||||
|
v-if="config?.actions.includes('subtraction')&&!state.treeData.edit&&visibleFormItem(config.subtractionShow, state.treeData)"
|
||||||
|
class="fl-ml-sm"
|
||||||
|
@click.stop="handleTreeSubtraction(state.treeData)" />
|
||||||
|
<n-icon :component="Add"
|
||||||
|
size="20"
|
||||||
|
v-if="config?.actions.includes('add')&&!state.treeData.edit&&visibleFormItem(config.addShow, state.treeData)"
|
||||||
|
class="fl-ml-sm"
|
||||||
|
@click.stop="handleTreeAdd(state.treeData)" />
|
||||||
|
<drag-outlined v-if="config?.actions.includes('move')&&!state.treeData.edit&&visibleFormItem(config.moveShow, state.treeData)"
|
||||||
|
class="fl-ml-sm"
|
||||||
|
@click.stop="handleTreeMove(state.treeData)" />
|
||||||
|
|
||||||
|
<!-- <n-icon :component="MoveOutline"
|
||||||
|
size="20"
|
||||||
|
v-if="config?.actions.includes('move')&&!state.treeData.edit&&visibleFormItem(config.moveShow, state.treeData)"
|
||||||
|
class="fl-ml-sm"
|
||||||
|
@click.stop="handleTreeMove(state.treeData)" /> -->
|
||||||
|
|
||||||
|
<n-icon :component="Checkmark"
|
||||||
|
size="20"
|
||||||
|
v-if="state.treeData.edit"
|
||||||
|
class="fl-ml-sm"
|
||||||
|
@click.stop="handleTreeSave(state.treeData)" />
|
||||||
|
<n-icon :component="Close"
|
||||||
|
size="20"
|
||||||
|
v-if="state.treeData.edit"
|
||||||
|
class="fl-ml-md"
|
||||||
|
@click.stop="handleTreeNotSave(state.treeData)" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
onBeforeMount,
|
||||||
|
onMounted,
|
||||||
|
watch,
|
||||||
|
reactive
|
||||||
|
} from "vue";
|
||||||
|
import {
|
||||||
|
visibleFormItem,
|
||||||
|
} from "@/utils/helper/form";
|
||||||
|
import {
|
||||||
|
UpOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
DragOutlined,
|
||||||
|
} from "@ant-design/icons-vue";
|
||||||
|
import { Add, Checkmark, Close, CreateOutline, Remove, MoveOutline } from "@vicons/ionicons5";
|
||||||
|
import { NPopover, NInput, NIcon } from "naive-ui";
|
||||||
|
let props = defineProps({
|
||||||
|
dataRef: Object,
|
||||||
|
checked: Boolean,
|
||||||
|
config: Object,
|
||||||
|
clickKey: [String, Number]
|
||||||
|
})
|
||||||
|
const state = reactive({
|
||||||
|
expandedKeys: [],
|
||||||
|
editTitle: '',
|
||||||
|
treeData: [],
|
||||||
|
});
|
||||||
|
onBeforeMount(() => {
|
||||||
|
state.treeData = props.dataRef.option
|
||||||
|
})
|
||||||
|
watch(() => props.dataRef.option, (val) => {
|
||||||
|
state.treeData = props.dataRef.option
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
})
|
||||||
|
const emit = defineEmits(["triggerTreeAction", "triggerTreeClick"]);
|
||||||
|
|
||||||
|
// const myComponentRef = ref(null);
|
||||||
|
const handleTreeEdit = () => {
|
||||||
|
state.editTitle = state.treeData.title
|
||||||
|
state.treeData.edit = true
|
||||||
|
// myComponentRef.value.$forceUpdate();
|
||||||
|
}
|
||||||
|
const handleTreeAdd = () => {
|
||||||
|
emit('triggerTreeAction', { type: 'add', val: state.treeData })
|
||||||
|
}
|
||||||
|
const handleTreeMove = () => {
|
||||||
|
emit('triggerTreeAction', { type: 'move', val: state.treeData })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTreeSubtraction = () => {
|
||||||
|
emit('triggerTreeAction', { type: 'subtraction', val: state.treeData })
|
||||||
|
}
|
||||||
|
const handleTreeSave = () => {
|
||||||
|
state.treeData.title = state.editTitle
|
||||||
|
emit('triggerTreeAction', { type: 'save', val: state.treeData })
|
||||||
|
}
|
||||||
|
const handleTreeNotSave = () => {
|
||||||
|
state.editTitle = ''
|
||||||
|
emit('triggerTreeAction', { type: 'cancel', val: state.treeData })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
70
src/components/search/highLightText.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<span>
|
||||||
|
<template v-for="(part, index) in parts" :key="index">
|
||||||
|
<span v-if="part.highlighted" :class="highlightClass">
|
||||||
|
{{ part.text }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ part.text }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
searchText: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
highlightClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'highlight',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const escapedSearchText = computed(() =>
|
||||||
|
String(props.searchText).replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pattern = computed(() => new RegExp(escapedSearchText.value, 'gi'))
|
||||||
|
|
||||||
|
const parts = computed(() => {
|
||||||
|
if (!props.searchText || !props.text)
|
||||||
|
return [{ text: props.text, highlighted: false }];
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
let currentIndex = 0;
|
||||||
|
const escapedSearchTextValue = escapedSearchText.value;
|
||||||
|
const searchPattern = new RegExp(`(${escapedSearchTextValue})`, 'gi');
|
||||||
|
|
||||||
|
props.text.replace(searchPattern, (match, p1, offset) => {
|
||||||
|
// 添加非高亮文本
|
||||||
|
if (currentIndex < offset) {
|
||||||
|
result.push({ text: props.text.slice(currentIndex, offset), highlighted: false });
|
||||||
|
}
|
||||||
|
// 添加高亮文本
|
||||||
|
result.push({ text: p1, highlighted: true });
|
||||||
|
// 更新当前索引
|
||||||
|
currentIndex = offset + p1.length;
|
||||||
|
return p1; // 这个返回值不影响最终结果,只是replace方法的要求
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加剩余的非高亮文本(如果有的话)
|
||||||
|
if (currentIndex < props.text.length) {
|
||||||
|
result.push({ text: props.text.slice(currentIndex), highlighted: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.highlight {
|
||||||
|
color: #7a58de;
|
||||||
|
}
|
||||||
|
</style>
|
1129
src/components/search/searchByCondition.vue
Normal file
383
src/components/search/searchItem.vue
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="search-item"
|
||||||
|
:class="props?.conditionType ? 'search-item-condition' : ''"
|
||||||
|
v-if="resultName"
|
||||||
|
:style="{
|
||||||
|
margin: props.searchResultKey === 'talk_record_infos_receiver' ? '12px 0 0' : '',
|
||||||
|
'background-color': props.isClickStay ? '#EEE9F8' : ''
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="search-item-avatar">
|
||||||
|
<avatarModule
|
||||||
|
:mode="props.searchItem?.group_type === 0 ? 1 : 2"
|
||||||
|
:avatar="avatarImg"
|
||||||
|
:userName="resultName"
|
||||||
|
:groupType="props.searchItem?.group_type"
|
||||||
|
:customStyle="{
|
||||||
|
width: props?.conditionType ? '32px' : '42px',
|
||||||
|
height: props?.conditionType ? '32px' : '42px',
|
||||||
|
margin: props?.conditionType ? '0 9px 0 0' : '0 10px 0 0'
|
||||||
|
}"
|
||||||
|
:customTextStyle="{
|
||||||
|
fontSize: props?.conditionType ? '10px' : '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#fff',
|
||||||
|
lineHeight: '24px'
|
||||||
|
}"
|
||||||
|
></avatarModule>
|
||||||
|
<div
|
||||||
|
class="info-tag"
|
||||||
|
v-if="resultType && !searchRecordDetail"
|
||||||
|
:style="'border-color:' + resultTypeColor"
|
||||||
|
>
|
||||||
|
<span class="text-[10px] font-medium" :style="'color:' + resultTypeColor">
|
||||||
|
{{ resultType }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-info">
|
||||||
|
<div class="info-name" :class="searchRecordDetail ? 'info-name-searchRecordDetail' : ''">
|
||||||
|
<HighlightText
|
||||||
|
:class="
|
||||||
|
props?.conditionType
|
||||||
|
? 'text-[14px] font-medium'
|
||||||
|
: searchRecordDetail
|
||||||
|
? 'text-[12px] font-medium'
|
||||||
|
: 'text-[14px] font-bold'
|
||||||
|
"
|
||||||
|
:text="resultName"
|
||||||
|
:searchText="props.searchText"
|
||||||
|
/>
|
||||||
|
<div class="info_num" v-if="groupNum">
|
||||||
|
<span class="text-[14px] font-medium">
|
||||||
|
{{ '(' + groupNum + ')' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="searchRecordDetail && chatRecordCreatedAt">
|
||||||
|
<span class="text-[12px] font-medium">
|
||||||
|
{{ chatRecordCreatedAt }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="info-detail"
|
||||||
|
v-if="resultDetail"
|
||||||
|
:class="searchRecordDetail ? 'info-detail-searchRecordDetail' : ''"
|
||||||
|
>
|
||||||
|
<HighlightText
|
||||||
|
class="text-[12px] font-regular"
|
||||||
|
:text="resultDetail"
|
||||||
|
:searchText="props.searchText"
|
||||||
|
/>
|
||||||
|
<div class="searchRecordDetail-fastLocal" v-if="searchRecordDetail">
|
||||||
|
<span>定位到聊天位置</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-item-pointer" v-if="pointerIconSrc">
|
||||||
|
<img :src="pointerIconSrc" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import avatarModule from '@/components/avatar-module/index.vue'
|
||||||
|
import { ref, watch, computed, onMounted, onUnmounted, reactive, defineProps } from 'vue'
|
||||||
|
import HighlightText from './highLightText.vue'
|
||||||
|
import { beautifyTime } from '@/utils/datetime'
|
||||||
|
import { ChatMsgTypeMapping } from '@/constant/message'
|
||||||
|
const props = defineProps({
|
||||||
|
searchItem: Object | Number,
|
||||||
|
searchResultKey: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
searchText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}, //搜索内容
|
||||||
|
searchRecordDetail: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}, //是否是搜索聊天记录详情
|
||||||
|
pointerIconSrc: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}, //箭头图标
|
||||||
|
conditionType: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}, //搜索类型
|
||||||
|
isClickStay: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
} //是否点击停留
|
||||||
|
})
|
||||||
|
// 映射表-查找对应结构下的属性名
|
||||||
|
const keyMapping = {
|
||||||
|
user_infos: { avatar: 'avatar', name: 'nickname' },
|
||||||
|
group_infos: { avatar: 'avatar', name: 'name', group_num: 'group_num' },
|
||||||
|
group_member_infos: {
|
||||||
|
avatar: 'group_avatar',
|
||||||
|
name: 'group_name',
|
||||||
|
detailKey: 'user_name',
|
||||||
|
group_num: 'group_num'
|
||||||
|
},
|
||||||
|
combinedGroup: {
|
||||||
|
avatar: props.searchItem?.groupTempType
|
||||||
|
? props.searchItem?.groupTempType === 'group_infos'
|
||||||
|
? 'avatar'
|
||||||
|
: props.searchItem?.groupTempType === 'group_member_infos'
|
||||||
|
? 'group_avatar'
|
||||||
|
: ''
|
||||||
|
: '',
|
||||||
|
name: props.searchItem?.groupTempType
|
||||||
|
? props.searchItem?.groupTempType === 'group_infos'
|
||||||
|
? 'name'
|
||||||
|
: props.searchItem?.groupTempType === 'group_member_infos'
|
||||||
|
? 'group_name'
|
||||||
|
: ''
|
||||||
|
: '',
|
||||||
|
detailKey: props.searchItem?.groupTempType
|
||||||
|
? props.searchItem?.groupTempType === 'group_member_infos'
|
||||||
|
? 'user_name'
|
||||||
|
: ''
|
||||||
|
: '',
|
||||||
|
group_num: props.searchItem?.groupTempType
|
||||||
|
? props.searchItem?.groupTempType === 'group_infos'
|
||||||
|
? 'group_num'
|
||||||
|
: props.searchItem?.groupTempType === 'group_member_infos'
|
||||||
|
? 'group_num'
|
||||||
|
: ''
|
||||||
|
: ''
|
||||||
|
},
|
||||||
|
general_infos: {
|
||||||
|
avatar: 'receiver_avatar',
|
||||||
|
name: 'receiver_name',
|
||||||
|
detailKey: 'count',
|
||||||
|
group_num: 'group_num'
|
||||||
|
},
|
||||||
|
talk_record_infos: {
|
||||||
|
avatar: 'user_avatar',
|
||||||
|
name: 'user_name',
|
||||||
|
detailKey: 'extra',
|
||||||
|
created_at: 'created_at'
|
||||||
|
},
|
||||||
|
talk_record_infos_receiver: {
|
||||||
|
avatar: 'receiver_avatar',
|
||||||
|
name: 'receiver_name',
|
||||||
|
group_num: 'group_num'
|
||||||
|
},
|
||||||
|
search_by_member_condition: {
|
||||||
|
avatar: 'avatar',
|
||||||
|
name: 'nickname',
|
||||||
|
created_at: 'created_at',
|
||||||
|
msg_type: 'msg_type',
|
||||||
|
detailKey: 'chatMessageType'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//获取key对应值
|
||||||
|
const getKeyValue = (keys) => {
|
||||||
|
let keyValue = ''
|
||||||
|
if (keys) {
|
||||||
|
keyValue = props?.searchItem ? props?.searchItem[keys] : ''
|
||||||
|
}
|
||||||
|
return keyValue
|
||||||
|
}
|
||||||
|
//头像
|
||||||
|
const avatarImg = computed(() => {
|
||||||
|
let avatar = getKeyValue(keyMapping[props.searchResultKey]?.avatar)
|
||||||
|
if (props?.conditionType) {
|
||||||
|
avatar = props.searchItem.avatar
|
||||||
|
}
|
||||||
|
return avatar
|
||||||
|
})
|
||||||
|
//名称
|
||||||
|
const resultName = computed(() => {
|
||||||
|
let result_name = getKeyValue(keyMapping[props.searchResultKey]?.name)
|
||||||
|
if (props?.conditionType) {
|
||||||
|
result_name = props.searchItem.nickname
|
||||||
|
}
|
||||||
|
return result_name
|
||||||
|
})
|
||||||
|
//文字头像
|
||||||
|
const imgText = computed(() => {
|
||||||
|
return resultName.value.length >= 2 ? resultName.value.slice(-2) : resultName.value
|
||||||
|
})
|
||||||
|
// 映射表-根据groupType设置对应值
|
||||||
|
const groupTypeMapping = {
|
||||||
|
0: {},
|
||||||
|
1: {},
|
||||||
|
2: {
|
||||||
|
result_type: '部门',
|
||||||
|
result_type_color: '#377EC6'
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
result_type: '项目',
|
||||||
|
result_type_color: '#C1681C'
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
result_type: '公司',
|
||||||
|
result_type_color: '#7A58DE'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//群人数
|
||||||
|
const groupNum = computed(() => {
|
||||||
|
return getKeyValue(keyMapping[props.searchResultKey]?.group_num)
|
||||||
|
})
|
||||||
|
//群类型tag
|
||||||
|
const resultType = computed(() => {
|
||||||
|
return groupTypeMapping[props.searchItem?.group_type]?.result_type
|
||||||
|
})
|
||||||
|
//群类型tag颜色
|
||||||
|
const resultTypeColor = computed(() => {
|
||||||
|
return groupTypeMapping[props.searchItem?.group_type]?.result_type_color
|
||||||
|
})
|
||||||
|
//搜索聊天记录详情-时间
|
||||||
|
const chatRecordCreatedAt = computed(() => {
|
||||||
|
let created_at = getKeyValue(keyMapping[props.searchResultKey]?.created_at)
|
||||||
|
return beautifyTime(created_at)
|
||||||
|
})
|
||||||
|
//详细内容
|
||||||
|
const resultDetail = computed(() => {
|
||||||
|
let result_detail = props.searchItem[keyMapping[props.searchResultKey]?.detailKey]
|
||||||
|
switch (keyMapping[props.searchResultKey]?.detailKey) {
|
||||||
|
case 'count':
|
||||||
|
result_detail = result_detail + '条聊天记录'
|
||||||
|
break
|
||||||
|
case 'user_name':
|
||||||
|
result_detail = '包含:' + result_detail
|
||||||
|
break
|
||||||
|
case 'extra':
|
||||||
|
result_detail = props.searchItem?.extra
|
||||||
|
break
|
||||||
|
case 'chatMessageType':
|
||||||
|
result_detail =
|
||||||
|
props.searchItem?.msg_type === 1
|
||||||
|
? props.searchItem?.extra?.content
|
||||||
|
: ChatMsgTypeMapping[props.searchItem?.msg_type]
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
result_detail = ''
|
||||||
|
}
|
||||||
|
return result_detail
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 11px 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.search-item-avatar {
|
||||||
|
position: relative;
|
||||||
|
.info-tag {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0px 6px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 4px;
|
||||||
|
span {
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
width: 100%;
|
||||||
|
.info-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
span {
|
||||||
|
color: #191919;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.info-name-searchRecordDetail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
span {
|
||||||
|
color: #999999;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.info-detail {
|
||||||
|
span {
|
||||||
|
color: #999999;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.info-detail-searchRecordDetail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
span {
|
||||||
|
color: #191919;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.searchRecordDetail-fastLocal {
|
||||||
|
display: none;
|
||||||
|
line-height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
span {
|
||||||
|
color: #46299d;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.search-item-pointer {
|
||||||
|
width: 5.5px;
|
||||||
|
height: 9px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.search-item::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 10px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
.search-item-condition {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.search-item:hover {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
|
||||||
|
.info-detail-searchRecordDetail {
|
||||||
|
.searchRecordDetail-fastLocal {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
783
src/components/search/searchList.vue
Normal file
@ -0,0 +1,783 @@
|
|||||||
|
<template>
|
||||||
|
<div class="search-list">
|
||||||
|
<n-infinite-scroll
|
||||||
|
:style="{ maxHeight: props.searchResultMaxHeight }"
|
||||||
|
:distance="47"
|
||||||
|
@load="doLoadMore"
|
||||||
|
>
|
||||||
|
<div class="search-result">
|
||||||
|
<div class="search-result-list">
|
||||||
|
<div
|
||||||
|
class="search-result-each-part"
|
||||||
|
v-for="(searchResultValue, searchResultKey, searchResultIndex) in state.searchResult"
|
||||||
|
:key="searchResultKey"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="search-result-part"
|
||||||
|
v-if="
|
||||||
|
Array.isArray(state?.searchResult[searchResultKey]) &&
|
||||||
|
state?.searchResult[searchResultKey].length > 0 &&
|
||||||
|
searchResultKey !== 'group_infos' &&
|
||||||
|
searchResultKey !== 'group_member_infos'
|
||||||
|
"
|
||||||
|
:style="{ margin: props.useCustomTitle ? '0' : '' }"
|
||||||
|
>
|
||||||
|
<!-- <div class="result-title" v-if="!props.useCustomTitle">
|
||||||
|
<span class="text-[14px] font-regular">
|
||||||
|
{{ getResultKeysValue(searchResultKey) }}
|
||||||
|
</span>
|
||||||
|
</div> -->
|
||||||
|
<slot
|
||||||
|
name="result-title"
|
||||||
|
:getResultKeysValue="getResultKeysValue"
|
||||||
|
:searchResultKey="searchResultKey"
|
||||||
|
:searchResultIndex="searchResultIndex"
|
||||||
|
></slot>
|
||||||
|
<div class="result-list">
|
||||||
|
<div
|
||||||
|
class="result-list-each"
|
||||||
|
v-for="(item, index) in state?.searchResult[searchResultKey]"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<searchItem
|
||||||
|
@click="clickSearchItem(searchResultKey, item)"
|
||||||
|
v-if="(
|
||||||
|
searchResultKey === 'user_infos'
|
||||||
|
? (state.userInfosShowAll || (props.listLimit && index < 3))
|
||||||
|
: searchResultKey === 'combinedGroup'
|
||||||
|
? (state.groupInfosShowAll || (props.listLimit && index < 3))
|
||||||
|
: (props.listLimit && index < 3)
|
||||||
|
) || !props.listLimit"
|
||||||
|
:searchResultKey="searchResultKey"
|
||||||
|
:searchItem="item"
|
||||||
|
:searchText="state.searchText"
|
||||||
|
:searchRecordDetail="props.searchRecordDetail"
|
||||||
|
:isClickStay="
|
||||||
|
props.useClickStay &&
|
||||||
|
typeof state.clickStayItem === 'string' &&
|
||||||
|
state.clickStayItem === `${item.talk_type}_${item.receiver_id}`
|
||||||
|
"
|
||||||
|
></searchItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="result-has-more"
|
||||||
|
v-if="
|
||||||
|
getHasMoreResult(searchResultKey) &&
|
||||||
|
!(
|
||||||
|
(searchResultKey === 'user_infos' && state.userInfosExpand) ||
|
||||||
|
(searchResultKey === 'combinedGroup' && state.groupInfosExpand)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="onMoreResultClick(searchResultKey)"
|
||||||
|
>
|
||||||
|
<span class="text-[14px] font-regular">
|
||||||
|
{{ getHasMoreResult(searchResultKey) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-infinite-scroll>
|
||||||
|
<!-- <ZPaging
|
||||||
|
ref="zPaging"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
v-model="state.searchResultList"
|
||||||
|
@query="queryAllSearch"
|
||||||
|
:default-page-no="state.pageNum"
|
||||||
|
:default-page-size="props.searchResultPageSize"
|
||||||
|
:loading-more-default-as-loading="true"
|
||||||
|
:inside-more="true"
|
||||||
|
:empty-view-img="searchNoData"
|
||||||
|
:empty-view-text="'检索您要查找的内容吧~'"
|
||||||
|
:empty-view-img-style="{ width: '238px', height: '131px' }"
|
||||||
|
:empty-view-title-style="{
|
||||||
|
color: '#999999',
|
||||||
|
margin: '-10px 0 0',
|
||||||
|
'line-height': '20px',
|
||||||
|
'font-size': '14px',
|
||||||
|
'font-weight': 400,
|
||||||
|
}"
|
||||||
|
:refresher-enabled="false"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<div class="searchRoot">
|
||||||
|
<customInput
|
||||||
|
:searchText="state.searchText"
|
||||||
|
:first_talk_record_infos="state.first_talk_record_infos"
|
||||||
|
@inputSearchText="inputSearchText"
|
||||||
|
></customInput>
|
||||||
|
<span
|
||||||
|
class="searchRoot_cancelBtn text-[16px] font-medium"
|
||||||
|
@click="cancelSearch"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="search-record-detail"
|
||||||
|
v-if="props.searchRecordDetail && !props?.hideFirstRecord"
|
||||||
|
>
|
||||||
|
<searchItem
|
||||||
|
@click="
|
||||||
|
clickSearchItem(
|
||||||
|
'talk_record_infos_receiver',
|
||||||
|
state?.first_talk_record_infos,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
searchResultKey="talk_record_infos_receiver"
|
||||||
|
:searchItem="state?.first_talk_record_infos"
|
||||||
|
:pointerIconSrc="pointerIconSrc"
|
||||||
|
></searchItem>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="search-result"
|
||||||
|
:style="
|
||||||
|
!state.searchText ? 'align-items:center;justify-content:center;' : ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</ZPaging> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
// import searchNoData from '@/static/image/search/search-no-data.png'
|
||||||
|
// import customInput from '@/components/custom-input/custom-input.vue'
|
||||||
|
// import pointerIconSrc from '@/static/image/search/search-item-pointer.png'
|
||||||
|
// import lodash from 'lodash'
|
||||||
|
// import { useUserStore } from '@/store'
|
||||||
|
// const userStore = useUserStore()
|
||||||
|
|
||||||
|
// import ZPaging from '@/uni_modules/z-paging/components/z-paging/z-paging.vue'
|
||||||
|
// import useZPaging from '@/uni_modules/z-paging/components/z-paging/js/hooks/useZPaging.js'
|
||||||
|
// const zPaging = ref()
|
||||||
|
// useZPaging(zPaging)
|
||||||
|
|
||||||
|
import { NInfiniteScroll } from 'naive-ui'
|
||||||
|
import searchItem from './searchItem.vue'
|
||||||
|
import { ref, reactive, defineEmits, defineProps, onMounted, watch } from 'vue'
|
||||||
|
import { ServeQueryUser, ServeQueryGroup } from '@/api/search'
|
||||||
|
|
||||||
|
const emits = defineEmits([
|
||||||
|
'toMoreResultPage',
|
||||||
|
'lastIdChange',
|
||||||
|
'clickSearchItem',
|
||||||
|
'clickStayItemChange',
|
||||||
|
'resultTotalCount'
|
||||||
|
])
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
searchText: '', //搜索内容
|
||||||
|
searchResultList: [], //搜素结果列表
|
||||||
|
searchResult: null, //搜索结果
|
||||||
|
pageNum: 1, //当前请求数据页数
|
||||||
|
uid: 12303, //当前用户id
|
||||||
|
clickStayItem: '', //点击停留的item
|
||||||
|
hasMore: true, //是否还有更多数据
|
||||||
|
loading: false, //加载锁
|
||||||
|
userInfosExpand: false, // 控制通讯录全部加载完
|
||||||
|
userInfosLoading: false, // 控制通讯录加载更多状态
|
||||||
|
userInfosLastId: undefined, // 记录通讯录分页的 last_id
|
||||||
|
userInfosShowAll: false, // 只要点过"更多通讯录"就为 true
|
||||||
|
groupInfosExpand: false, // 控制群聊全部加载完
|
||||||
|
groupInfosLoading: false, // 控制群聊加载更多状态
|
||||||
|
groupInfosLastGroupId: 0, // 记录群聊分页的 last_group_id
|
||||||
|
groupInfosLastMemberId: 0, // 记录群聊分页的 last_member_id
|
||||||
|
groupInfosShowAll: false // 只要点过"更多群聊"就为 true
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
searchResultPageSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}, //搜索结果每页数据量
|
||||||
|
listLimit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}, //是否限制列表内数据数量
|
||||||
|
apiParams: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}, //请求参数
|
||||||
|
apiRequest: Function, //请求
|
||||||
|
searchText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}, //搜索内容
|
||||||
|
isPagination: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}, //是否分页
|
||||||
|
searchRecordDetail: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}, //是否是搜索聊天记录的详情
|
||||||
|
first_talk_record_infos: {
|
||||||
|
type: Object,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}, //接受者信息
|
||||||
|
hideFirstRecord: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}, //是否隐藏前缀及搜索群/用户主体信息
|
||||||
|
useClickStay: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}, //是否使用点击停留样式
|
||||||
|
searchResultMaxHeight: {
|
||||||
|
type: String,
|
||||||
|
default: '677px'
|
||||||
|
}, //搜索结果最大高度
|
||||||
|
useCustomTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}, //是否使用自定义标题
|
||||||
|
selectItemInList: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
} //在列表选中的聊天记录搜索项
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.searchText) {
|
||||||
|
state.searchText = props.searchText
|
||||||
|
queryAllSearch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听每页数量变化
|
||||||
|
watch(
|
||||||
|
() => props.searchResultPageSize,
|
||||||
|
(newVal, oldVal) => {
|
||||||
|
queryAllSearch()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听搜索文本变化
|
||||||
|
watch(
|
||||||
|
() => props.searchText,
|
||||||
|
(newVal, oldVal) => {
|
||||||
|
// 同步更新 state.searchText
|
||||||
|
state.searchText = newVal
|
||||||
|
// 清空搜索结果
|
||||||
|
state.searchResult = null
|
||||||
|
// 重置页码
|
||||||
|
state.pageNum = 1
|
||||||
|
//重置点击停留列表项
|
||||||
|
state.clickStayItem = ''
|
||||||
|
emits('clickStayItemChange', state.clickStayItem)
|
||||||
|
//重置搜索条件
|
||||||
|
emits('lastIdChange', 0, 0, 0, '', '')
|
||||||
|
state.userInfosExpand = false
|
||||||
|
state.userInfosShowAll = false
|
||||||
|
state.userInfosLastId = undefined
|
||||||
|
state.groupInfosExpand = false
|
||||||
|
state.groupInfosShowAll = false
|
||||||
|
state.groupInfosLastGroupId = 0
|
||||||
|
state.groupInfosLastMemberId = 0
|
||||||
|
queryAllSearch()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ES搜索聊天记录-主页搜索什么都有、指定用户、指定群、群与用户概览
|
||||||
|
const queryAllSearch = (doClearSearchResult) => {
|
||||||
|
if (doClearSearchResult) {
|
||||||
|
state.searchResult = null
|
||||||
|
}
|
||||||
|
let params = {
|
||||||
|
key: state.searchText, //关键字
|
||||||
|
size: props.searchResultPageSize
|
||||||
|
}
|
||||||
|
if (props.apiParams) {
|
||||||
|
let apiParams = JSON.parse(decodeURIComponent(props.apiParams))
|
||||||
|
params = Object.assign({}, params, apiParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = props.apiRequest(params)
|
||||||
|
resp.then(({ code, data }) => {
|
||||||
|
console.log(data)
|
||||||
|
if (code == 200) {
|
||||||
|
if ((data.user_infos || []).length > 0) {
|
||||||
|
;(data.user_infos || []).forEach((item) => {
|
||||||
|
item.group_type = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ((data.group_infos || []).length > 0) {
|
||||||
|
;(data.group_infos || []).forEach((item) => {
|
||||||
|
item.group_type = item.type
|
||||||
|
item.groupTempType = 'group_infos'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ((data.group_member_infos || []).length > 0) {
|
||||||
|
;(data.group_member_infos || []).forEach((item) => {
|
||||||
|
item.groupTempType = 'group_member_infos'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ((data.talk_record_infos || []).length > 0) {
|
||||||
|
let receiverInfo = JSON.parse(JSON.stringify(data.talk_record_infos[0]))
|
||||||
|
if (receiverInfo.talk_type === 1) {
|
||||||
|
//单聊才需此判断
|
||||||
|
if (receiverInfo.user_id === state.uid) {
|
||||||
|
//发送人是自己,接收人不需要变
|
||||||
|
}
|
||||||
|
if (receiverInfo.receiver_id === state.uid) {
|
||||||
|
//接收人是自己,这里需要变成对方
|
||||||
|
let temp_id = receiverInfo.receiver_id
|
||||||
|
let temp_name = receiverInfo.receiver_name
|
||||||
|
let temp_avatar = receiverInfo.receiver_avatar
|
||||||
|
receiverInfo.receiver_id = receiverInfo.user_id
|
||||||
|
receiverInfo.receiver_name = receiverInfo.user_name
|
||||||
|
receiverInfo.receiver_avatar = receiverInfo.user_avatar
|
||||||
|
receiverInfo.user_id = temp_id
|
||||||
|
receiverInfo.user_name = temp_name
|
||||||
|
receiverInfo.user_avatar = temp_avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.first_talk_record_infos = Object.assign(
|
||||||
|
{},
|
||||||
|
state.first_talk_record_infos,
|
||||||
|
receiverInfo
|
||||||
|
)
|
||||||
|
;(data.talk_record_infos || []).forEach((item) => {
|
||||||
|
item.group_type = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let tempGeneral_infos = Array.isArray(data.general_infos)
|
||||||
|
? [...data.general_infos]
|
||||||
|
: data.general_infos
|
||||||
|
delete data.general_infos
|
||||||
|
data.combinedGroup = (data.group_infos || []).concat(data.group_member_infos || [])
|
||||||
|
data.general_infos = tempGeneral_infos
|
||||||
|
|
||||||
|
// 检查数据是否为空
|
||||||
|
let isEmpty = true
|
||||||
|
let dataKeys = Object.keys(data)
|
||||||
|
let paginationKey = ''
|
||||||
|
dataKeys.forEach((item) => {
|
||||||
|
if (Array.isArray(data[item]) && data[item].length > 0) {
|
||||||
|
paginationKey = item
|
||||||
|
isEmpty = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
if (state.pageNum === 1) {
|
||||||
|
// 第一页请求且为空,清空结果
|
||||||
|
state.searchResult = null
|
||||||
|
// zPaging.value?.complete([])
|
||||||
|
} else {
|
||||||
|
// 加载更多且为空,保持原列表不变
|
||||||
|
// zPaging.value?.complete(state.searchResult ? [state.searchResult] : [])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (props.isPagination) {
|
||||||
|
if (state.pageNum === 1) {
|
||||||
|
// 第一页请求,直接设置新数据
|
||||||
|
state.searchResult = data
|
||||||
|
} else {
|
||||||
|
// 加载更多,合并数据
|
||||||
|
data[paginationKey] = (state.searchResult?.[paginationKey] || []).concat(
|
||||||
|
data[paginationKey]
|
||||||
|
)
|
||||||
|
state.searchResult = data
|
||||||
|
}
|
||||||
|
|
||||||
|
emits(
|
||||||
|
'lastIdChange',
|
||||||
|
data.last_id,
|
||||||
|
data.last_group_id,
|
||||||
|
data.last_member_id,
|
||||||
|
data.last_receiver_user_name,
|
||||||
|
data.last_receiver_group_name
|
||||||
|
)
|
||||||
|
let total = data.count
|
||||||
|
if (props.searchRecordDetail) {
|
||||||
|
if (state?.first_talk_record_infos?.talk_type === 1) {
|
||||||
|
total = data.user_record_count
|
||||||
|
} else if (state?.first_talk_record_infos?.talk_type === 2) {
|
||||||
|
total = data.group_record_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total < props.searchResultPageSize) {
|
||||||
|
state.hasMore = false
|
||||||
|
} else {
|
||||||
|
state.hasMore = true
|
||||||
|
}
|
||||||
|
emits('resultTotalCount', total)
|
||||||
|
// zPaging.value?.completeByTotal([data], total)
|
||||||
|
} else {
|
||||||
|
state.searchResult = data
|
||||||
|
// zPaging.value?.complete([data])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.pageNum = state.pageNum + 1
|
||||||
|
// 同步 userInfosLastId
|
||||||
|
if (typeof data.last_id !== 'undefined') {
|
||||||
|
state.userInfosLastId = data.last_id
|
||||||
|
} else {
|
||||||
|
state.userInfosLastId = undefined
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (state.pageNum === 1) {
|
||||||
|
// 第一页请求失败,清空结果
|
||||||
|
state.searchResult = null
|
||||||
|
// zPaging.value?.complete([])
|
||||||
|
} else {
|
||||||
|
// 加载更多失败,保持原列表不变
|
||||||
|
// zPaging.value?.complete(state.searchResult ? [state.searchResult] : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resp.catch(() => {
|
||||||
|
if (state.pageNum === 1) {
|
||||||
|
// 第一页请求异常,清空结果
|
||||||
|
state.searchResult = null
|
||||||
|
// zPaging.value?.complete([])
|
||||||
|
} else {
|
||||||
|
// 加载更多异常,保持原列表不变
|
||||||
|
// zPaging.value?.complete(state.searchResult ? [state.searchResult] : [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
//点击取消搜索
|
||||||
|
const cancelSearch = () => {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack({
|
||||||
|
delta: 1
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
uni.reLaunch({
|
||||||
|
url: '/pages/index/index'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//获取key对应值
|
||||||
|
const getResultKeysValue = (keys) => {
|
||||||
|
let resultKey = ''
|
||||||
|
switch (keys) {
|
||||||
|
case 'user_infos':
|
||||||
|
resultKey = '通讯录'
|
||||||
|
break
|
||||||
|
case 'group_infos':
|
||||||
|
resultKey = '群聊'
|
||||||
|
break
|
||||||
|
case 'group_member_infos':
|
||||||
|
resultKey = '群聊'
|
||||||
|
break
|
||||||
|
case 'combinedGroup':
|
||||||
|
resultKey = '群聊'
|
||||||
|
break
|
||||||
|
case 'general_infos':
|
||||||
|
resultKey = '聊天记录'
|
||||||
|
break
|
||||||
|
case 'talk_record_infos':
|
||||||
|
resultKey = '相关聊天记录'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
resultKey = ''
|
||||||
|
}
|
||||||
|
return resultKey
|
||||||
|
}
|
||||||
|
|
||||||
|
//是否还有更多数据
|
||||||
|
const getHasMoreResult = (searchResultKey) => {
|
||||||
|
let has_more_result = ''
|
||||||
|
switch (searchResultKey) {
|
||||||
|
case 'user_infos':
|
||||||
|
if (state.searchResult['user_count'] && state.searchResult['user_count'] > 3) {
|
||||||
|
has_more_result = '更多通讯录'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'group_infos':
|
||||||
|
if (state.searchResult['group_count'] && state.searchResult['group_count'] > 3) {
|
||||||
|
has_more_result = '更多群聊'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'group_member_infos':
|
||||||
|
if (state.searchResult['group_count'] && state.searchResult['group_count'] > 3) {
|
||||||
|
has_more_result = '更多群聊'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'combinedGroup':
|
||||||
|
if (state.searchResult['group_count'] && state.searchResult['group_count'] > 3) {
|
||||||
|
has_more_result = '更多群聊'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'general_infos':
|
||||||
|
if (state.searchResult['record_count'] && state.searchResult['record_count'] >= 3) {
|
||||||
|
has_more_result = '更多聊天记录'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return has_more_result
|
||||||
|
}
|
||||||
|
|
||||||
|
//点击跳转到更多结果页面
|
||||||
|
const toMoreResultPage = (searchResultKey) => {
|
||||||
|
emits('toMoreResultPage', searchResultKey, state.searchText)
|
||||||
|
}
|
||||||
|
|
||||||
|
//点击了搜索结果项
|
||||||
|
const clickSearchItem = (searchResultKey, searchItem) => {
|
||||||
|
console.log(searchResultKey, searchItem)
|
||||||
|
if (props.useClickStay) {
|
||||||
|
state.clickStayItem = searchItem.talk_type + '_' + searchItem.receiver_id
|
||||||
|
} else {
|
||||||
|
state.clickStayItem = ''
|
||||||
|
}
|
||||||
|
emits('clickStayItemChange', state.clickStayItem)
|
||||||
|
let talk_type = searchItem.talk_type
|
||||||
|
let receiver_id = searchItem.receiver_id
|
||||||
|
if (searchResultKey === 'user_infos') {
|
||||||
|
talk_type = 1
|
||||||
|
receiver_id = searchItem.id
|
||||||
|
} else if (searchResultKey === 'combinedGroup') {
|
||||||
|
talk_type = searchItem.type || 2
|
||||||
|
receiver_id = searchItem.group_id || searchItem.id
|
||||||
|
} else if (searchResultKey === 'general_infos') {
|
||||||
|
if (searchItem.talk_type === 1) {
|
||||||
|
if (searchItem.user_id === state.uid) {
|
||||||
|
//发送人是自己,接收人不需要变
|
||||||
|
}
|
||||||
|
if (searchItem.receiver_id === state.uid) {
|
||||||
|
//接收人是自己,这里需要变成对方
|
||||||
|
let temp_id = searchItem.receiver_id
|
||||||
|
let temp_name = searchItem.receiver_name
|
||||||
|
let temp_avatar = searchItem.receiver_avatar
|
||||||
|
searchItem.receiver_id = searchItem.user_id
|
||||||
|
searchItem.receiver_name = searchItem.user_name
|
||||||
|
searchItem.receiver_avatar = searchItem.user_avatar
|
||||||
|
searchItem.user_id = temp_id
|
||||||
|
searchItem.user_name = temp_name
|
||||||
|
searchItem.user_avatar = temp_avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emits(
|
||||||
|
'clickSearchItem',
|
||||||
|
state.searchText,
|
||||||
|
searchResultKey,
|
||||||
|
talk_type,
|
||||||
|
receiver_id,
|
||||||
|
encodeURIComponent(JSON.stringify(searchItem))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//加载更多数据
|
||||||
|
const doLoadMore = (doClearSearchResult) => {
|
||||||
|
if (
|
||||||
|
state.userInfosLoading ||
|
||||||
|
state.userInfosShowAll ||
|
||||||
|
state.groupInfosShowAll // 新增判断,群聊展开后不再触发 queryAllSearch
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!state.hasMore || state.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.loading = true
|
||||||
|
queryAllSearch(doClearSearchResult).finally(() => {
|
||||||
|
state.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectItemInList,
|
||||||
|
(newVal, oldVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
const selectedItem = JSON.parse(decodeURIComponent(newVal))
|
||||||
|
clickSearchItem('general_infos', selectedItem)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 单独维护通讯录加载更多逻辑,基于 last_id 分页
|
||||||
|
async function loadMoreUserInfos() {
|
||||||
|
if (state.userInfosLoading) return
|
||||||
|
state.userInfosLoading = true
|
||||||
|
try {
|
||||||
|
let params = {
|
||||||
|
key: state.searchText,
|
||||||
|
last_id: state.userInfosLastId,
|
||||||
|
size: 10
|
||||||
|
}
|
||||||
|
const resp = await ServeQueryUser(params)
|
||||||
|
if (resp.code === 200 && Array.isArray(resp.data.user_infos)) {
|
||||||
|
if (!state.userInfosLastId) {
|
||||||
|
// 第一次加载,直接替换
|
||||||
|
state.searchResult = {
|
||||||
|
...state.searchResult,
|
||||||
|
user_infos: resp.data.user_infos
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 后续加载,追加
|
||||||
|
state.searchResult = {
|
||||||
|
...state.searchResult,
|
||||||
|
user_infos: (state.searchResult.user_infos || []).concat(resp.data.user_infos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.userInfosLastId = resp.data.last_id
|
||||||
|
// 判断是否全部加载完
|
||||||
|
if (
|
||||||
|
!resp.data.last_id ||
|
||||||
|
(Array.isArray(resp.data.user_infos) && resp.data.user_infos.length < 10)
|
||||||
|
) {
|
||||||
|
state.userInfosExpand = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.userInfosLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理"更多通讯录"、 "更多群聊"点击,调用新方法
|
||||||
|
function onMoreResultClick(searchResultKey) {
|
||||||
|
if (searchResultKey === 'user_infos') {
|
||||||
|
state.userInfosShowAll = true
|
||||||
|
loadMoreUserInfos()
|
||||||
|
} else if (searchResultKey === 'combinedGroup') {
|
||||||
|
state.groupInfosShowAll = true
|
||||||
|
loadMoreGroupInfos()
|
||||||
|
} else {
|
||||||
|
emits('toMoreResultPage', searchResultKey, state.searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单独维护群聊加载更多逻辑,基于 last_id 分页
|
||||||
|
async function loadMoreGroupInfos() {
|
||||||
|
if (state.groupInfosLoading) return
|
||||||
|
state.groupInfosLoading = true
|
||||||
|
try {
|
||||||
|
let params = {
|
||||||
|
key: state.searchText,
|
||||||
|
last_group_id: state.groupInfosLastGroupId,
|
||||||
|
last_member_id: state.groupInfosLastMemberId,
|
||||||
|
size: 10
|
||||||
|
}
|
||||||
|
const resp = await ServeQueryGroup(params)
|
||||||
|
if (resp.code === 200) {
|
||||||
|
const groupInfos = Array.isArray(resp.data.group_infos) ? resp.data.group_infos : []
|
||||||
|
const groupMemberInfos = Array.isArray(resp.data.group_member_infos) ? resp.data.group_member_infos : []
|
||||||
|
|
||||||
|
// 给新数据加上 groupTempType
|
||||||
|
groupInfos.forEach(item => {
|
||||||
|
item.groupTempType = 'group_infos'
|
||||||
|
item.group_type = item.type // 保持一致性
|
||||||
|
})
|
||||||
|
groupMemberInfos.forEach(item => {
|
||||||
|
item.groupTempType = 'group_member_infos'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFirstLoad = (!state.groupInfosLastGroupId && !state.groupInfosLastMemberId) ||
|
||||||
|
(state.groupInfosLastGroupId === 0 && state.groupInfosLastMemberId === 0)
|
||||||
|
if (isFirstLoad) {
|
||||||
|
// 第一次加载,直接替换
|
||||||
|
state.searchResult = {
|
||||||
|
...state.searchResult,
|
||||||
|
group_infos: groupInfos,
|
||||||
|
group_member_infos: groupMemberInfos,
|
||||||
|
combinedGroup: groupInfos.concat(groupMemberInfos)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 后续加载,追加
|
||||||
|
const allGroupInfos = (state.searchResult.group_infos || []).concat(groupInfos)
|
||||||
|
const allGroupMemberInfos = (state.searchResult.group_member_infos || []).concat(groupMemberInfos)
|
||||||
|
state.searchResult = {
|
||||||
|
...state.searchResult,
|
||||||
|
group_infos: allGroupInfos,
|
||||||
|
group_member_infos: allGroupMemberInfos,
|
||||||
|
combinedGroup: allGroupInfos.concat(allGroupMemberInfos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.groupInfosLastGroupId = resp.data.last_group_id
|
||||||
|
state.groupInfosLastMemberId = resp.data.last_member_id
|
||||||
|
// 判断是否全部加载完
|
||||||
|
const noMoreData = (
|
||||||
|
(!groupInfos.length && !groupMemberInfos.length) ||
|
||||||
|
(resp.data.last_group_id === 0 && resp.data.last_member_id === 0)
|
||||||
|
)
|
||||||
|
if (noMoreData) {
|
||||||
|
state.groupInfosExpand = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.groupInfosLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-list {
|
||||||
|
.searchRoot {
|
||||||
|
padding: 10px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
.searchRoot_cancelBtn {
|
||||||
|
line-height: 22px;
|
||||||
|
color: #46299d;
|
||||||
|
margin: 0 0 0 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.search-record-detail {
|
||||||
|
padding: 0 25px;
|
||||||
|
}
|
||||||
|
.search-result {
|
||||||
|
width: 100%;
|
||||||
|
// padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.search-result-list {
|
||||||
|
width: 100%;
|
||||||
|
// padding: 0 10px;
|
||||||
|
|
||||||
|
.search-result-part {
|
||||||
|
// margin: 18px 0 0;
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
padding: 0 10px 5px;
|
||||||
|
border-bottom: 1px solid #f8f8f8;
|
||||||
|
span {
|
||||||
|
line-height: 20px;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.result-has-more {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #f8f8f8;
|
||||||
|
cursor: pointer;
|
||||||
|
span {
|
||||||
|
color: #191919;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.result-has-more:hover {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -5,22 +5,21 @@ import { ServeGetForwardRecords } from '@/api/chat'
|
|||||||
import { MessageComponents } from '@/constant/message'
|
import { MessageComponents } from '@/constant/message'
|
||||||
import { ITalkRecord } from '@/types/chat'
|
import { ITalkRecord } from '@/types/chat'
|
||||||
import { useInject } from '@/hooks'
|
import { useInject } from '@/hooks'
|
||||||
|
import customModal from '@/components/common/customModal.vue'
|
||||||
const emit = defineEmits(['close'])
|
import { voiceToText } from '@/api/chat.js'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
msgId: {
|
msgId: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const isShow=defineModel<boolean>('show')
|
||||||
const { showUserInfoModal } = useInject()
|
const { showUserInfoModal } = useInject()
|
||||||
const isShow = ref(true)
|
|
||||||
const items = ref<ITalkRecord[]>([])
|
const items = ref<ITalkRecord[]>([])
|
||||||
const title = ref('会话记录')
|
const title = ref('会话记录')
|
||||||
|
|
||||||
const onMaskClick = () => {
|
const onMaskClick = () => {
|
||||||
emit('close')
|
isShow.value=false
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLoadData = () => {
|
const onLoadData = () => {
|
||||||
@ -30,18 +29,92 @@ const onLoadData = () => {
|
|||||||
if (res.code == 200) {
|
if (res.code == 200) {
|
||||||
items.value = res.data.items || []
|
items.value = res.data.items || []
|
||||||
|
|
||||||
title.value = `会话记录(${items.value.length})`
|
// title.value = `会话记录(${items.value.length})`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const dropdown=ref({
|
||||||
|
show:false,
|
||||||
|
x:'',
|
||||||
|
y:'',
|
||||||
|
options:[] as any,
|
||||||
|
item:{} as ITalkRecord,
|
||||||
|
})
|
||||||
|
const onConvertText =async (data: ITalkRecord) => {
|
||||||
|
data.is_convert_text = 1
|
||||||
|
const res = await voiceToText({msgId:data.msg_id,voiceUrl:data.extra.url})
|
||||||
|
if(res.code == 200){
|
||||||
|
data.extra.content = res.data.convText
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onloseConvertText=(data: ITalkRecord)=>{
|
||||||
|
data.is_convert_text = 0
|
||||||
|
}
|
||||||
|
const evnets = {
|
||||||
|
convertText: onConvertText,
|
||||||
|
closeConvertText:onloseConvertText
|
||||||
|
}
|
||||||
|
|
||||||
|
const onContextMenuHandle=(key:string)=>{
|
||||||
|
evnets[key] && evnets[key](dropdown.value.item)
|
||||||
|
closeDropdownMenu()
|
||||||
|
}
|
||||||
|
const closeDropdownMenu=()=>{
|
||||||
|
dropdown.value.show=false
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
onLoadData()
|
onLoadData()
|
||||||
})
|
})
|
||||||
|
const onContextMenu = (e:any,item: ITalkRecord) => {
|
||||||
|
dropdown.value.show=true
|
||||||
|
|
||||||
|
dropdown.value.x=e.clientX
|
||||||
|
dropdown.value.y=e.clientY
|
||||||
|
if(item.is_convert_text === 1){
|
||||||
|
dropdown.value.options=[{ label: '关闭转文字', key: 'closeConvertText' }]
|
||||||
|
}else{
|
||||||
|
dropdown.value.options=[{ label: '转文字', key: 'convertText' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.value.item=item
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal
|
<customModal :closable="false" customCloseBtn v-model:show="isShow" :title="title" style="width: 997px;background-color: #F9F9FD;" :on-after-leave="onMaskClick">
|
||||||
|
<template #content>
|
||||||
|
<div class="main-box bg-#fff me-scrollbar me-scrollbar-thumb">
|
||||||
|
<Loading v-if="items.length === 0" />
|
||||||
|
|
||||||
|
<div v-for="item in items" :key="item.msg_id" class="message-item">
|
||||||
|
<div class="left-box pointer" @click="showUserInfoModal(item.erp_user_id)">
|
||||||
|
<im-avatar :src="item.avatar" :size="38" :username="item.nickname" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-box">
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="name">{{ item.nickname }}</span>
|
||||||
|
<span class="time"> {{ item.created_at }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<component
|
||||||
|
@contextmenu.prevent="onContextMenu($event,item)"
|
||||||
|
:is="MessageComponents[item.msg_type] || 'unknown-message'"
|
||||||
|
:extra="item.extra"
|
||||||
|
:data="item"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<n-dropdown :show="dropdown.show" :x="dropdown.x" :y="dropdown.y" style="width: 142px;" :options="dropdown.options"
|
||||||
|
@select="onContextMenuHandle" @clickoutside="closeDropdownMenu" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</customModal>
|
||||||
|
<!-- <n-modal
|
||||||
v-model:show="isShow"
|
v-model:show="isShow"
|
||||||
preset="card"
|
preset="card"
|
||||||
:title="title"
|
:title="title"
|
||||||
@ -80,7 +153,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-modal>
|
</n-modal> -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@ -94,10 +167,12 @@ onMounted(() => {
|
|||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
padding: 5px 15px;
|
padding: 24px 42px;
|
||||||
|
.im-message-text{
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
.left-box {
|
.left-box {
|
||||||
width: 30px;
|
width: 38px;
|
||||||
display: flex;
|
display: flex;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
|
@ -3,7 +3,7 @@ import { ref, reactive } from 'vue'
|
|||||||
import { PlayOne, PauseOne } from '@icon-park/vue-next'
|
import { PlayOne, PauseOne } from '@icon-park/vue-next'
|
||||||
import { ITalkRecordExtraAudio, ITalkRecord } from '@/types/chat'
|
import { ITalkRecordExtraAudio, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
extra: ITalkRecordExtraAudio
|
extra: ITalkRecordExtraAudio
|
||||||
data: ITalkRecord
|
data: ITalkRecord
|
||||||
maxWidth?: Boolean
|
maxWidth?: Boolean
|
||||||
@ -18,7 +18,8 @@ const state = reactive({
|
|||||||
progress: 0,
|
progress: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
loading: true
|
loading: true,
|
||||||
|
showText: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPlay = () => {
|
const onPlay = () => {
|
||||||
@ -40,6 +41,12 @@ const onCanplay = () => {
|
|||||||
state.duration = audioRef.value.duration
|
state.duration = audioRef.value.duration
|
||||||
durationDesc.value = formatTime(parseInt(audioRef.value.duration))
|
durationDesc.value = formatTime(parseInt(audioRef.value.duration))
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
|
||||||
|
if (props.data.is_convert_text === 1 && props.data.extra.content) {
|
||||||
|
setTimeout(() => {
|
||||||
|
state.showText = true
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onError = (e: any) => {
|
const onError = (e: any) => {
|
||||||
@ -61,17 +68,12 @@ const formatTime = (value: number = 0) => {
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
const minutes = Math.floor(value / 60)
|
return `${Math.floor(value)}"`
|
||||||
let seconds = value
|
|
||||||
if (minutes > 0) {
|
|
||||||
seconds = Math.floor(value - minutes * 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${minutes}'${seconds}"`
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="im-message-audio">
|
<div class="pointer w-200px bg-#f5f5f5 rounded-10px px-11px">
|
||||||
|
<div class="im-message-audio h-44px">
|
||||||
<audio
|
<audio
|
||||||
ref="audioRef"
|
ref="audioRef"
|
||||||
preload="auto"
|
preload="auto"
|
||||||
@ -98,20 +100,27 @@ const formatTime = (value: number = 0) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="time">{{ durationDesc }}</div>
|
<div class="time">{{ durationDesc }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<transition name="expand">
|
||||||
|
<div class="text-container py-12px border-t-2px border-t-solid border-t-#E0E0E4" v-if="data.is_convert_text===1">
|
||||||
|
<div class="flex justify-center items-center" v-if="data.is_convert_text===1&&!data.extra.content">
|
||||||
|
<n-spin :stroke-width="3" size="small" />
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<div class="text-content" v-if="data.extra.content">{{ data.extra.content }}</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.im-message-audio {
|
.im-message-audio {
|
||||||
--audio-bg-color: #f5f5f5;
|
--audio-bg-color: #f5f5f5;
|
||||||
--audio-btn-bg-color: #ffffff;
|
--audio-btn-bg-color: #ffffff;
|
||||||
|
|
||||||
width: 200px;
|
|
||||||
height: 45px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--audio-bg-color);
|
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -132,6 +141,7 @@ const formatTime = (value: number = 0) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,6 +240,7 @@ const formatTime = (value: number = 0) => {
|
|||||||
height: 70%;
|
height: 70%;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background-color: #9b9595;
|
background-color: #9b9595;
|
||||||
|
transition: left 0.1s linear;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,6 +252,40 @@ const formatTime = (value: number = 0) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.expand-enter-active,
|
||||||
|
.expand-leave-active {
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-enter-from,
|
||||||
|
.expand-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-container {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content {
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
html[theme-mode='dark'] {
|
html[theme-mode='dark'] {
|
||||||
.im-message-audio {
|
.im-message-audio {
|
||||||
--audio-bg-color: #2c2c32;
|
--audio-bg-color: #2c2c32;
|
||||||
|
@ -1,118 +1,260 @@
|
|||||||
<script lang="ts" setup>
|
<script setup>
|
||||||
import { fileFormatSize } from '@/utils/strings'
|
import { fileFormatSize } from '@/utils/strings'
|
||||||
import { download, getFileNameSuffix } from '@/utils/functions'
|
import { ref, computed } from 'vue'
|
||||||
import { ITalkRecordExtraFile, ITalkRecord } from '@/types/chat'
|
import { useUploadsStore } from '@/store'
|
||||||
|
import pptText from '@/assets/image/ppt-text.png'
|
||||||
|
import excelText from '@/assets/image/excel-text.png'
|
||||||
|
import wordText from '@/assets/image/word-text.png'
|
||||||
|
import pdfText from '@/assets/image/pdf-text.png'
|
||||||
|
import fileText from '@/assets/image/file-text.png'
|
||||||
|
import { ArrowDownload16Filled } from '@vicons/fluent'
|
||||||
|
import { download } from '@/utils/functions.js'
|
||||||
|
// 定义组件属性
|
||||||
|
const props = defineProps({
|
||||||
|
// 文件的额外信息
|
||||||
|
extra: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
// 聊天记录数据
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
// 是否使用最大宽度
|
||||||
|
maxWidth: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
defineProps<{
|
const uploadsStore = useUploadsStore()
|
||||||
extra: ITalkRecordExtraFile
|
const isPlaying = ref(false)
|
||||||
data: ITalkRecord
|
|
||||||
maxWidth?: Boolean
|
// 文件类型配置
|
||||||
}>()
|
const fileTypes = {
|
||||||
|
PDF: { icon: pdfText, color: '#DE4E4E', type: 'PDF' },
|
||||||
|
PPT: { icon: pptText, color: '#B74B2B', type: 'PPT' },
|
||||||
|
EXCEL: { icon: excelText, color: '#3C7F4B', type: 'EXCEL' },
|
||||||
|
WORD: { icon: wordText, color: '#2750B2', type: 'WORD' },
|
||||||
|
DEFAULT: { icon: fileText, color: '#747474', type: '文件' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excel文件扩展名映射
|
||||||
|
const EXCEL_EXTENSIONS = ['XLS', 'XLSX', 'CSV']
|
||||||
|
// Word文件扩展名映射
|
||||||
|
const WORD_EXTENSIONS = ['DOC', 'DOCX', 'RTF', 'DOT', 'DOTX']
|
||||||
|
// PPT文件扩展名映射
|
||||||
|
const PPT_EXTENSIONS = ['PPT', 'PPTX', 'PPS', 'PPSX']
|
||||||
|
|
||||||
|
// 获取文件类型信息
|
||||||
|
const fileInfo = computed(() => {
|
||||||
|
const extension = getFileExtension(props.extra.name)
|
||||||
|
if (EXCEL_EXTENSIONS.includes(extension)) {
|
||||||
|
return fileTypes.EXCEL
|
||||||
|
}
|
||||||
|
if (WORD_EXTENSIONS.includes(extension)) {
|
||||||
|
return fileTypes.WORD
|
||||||
|
}
|
||||||
|
if (PPT_EXTENSIONS.includes(extension)) {
|
||||||
|
return fileTypes.PPT
|
||||||
|
}
|
||||||
|
return fileTypes[extension] || fileTypes.DEFAULT
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取文件扩展名
|
||||||
|
function getFileExtension(filename) {
|
||||||
|
const parts = filename.split('.')
|
||||||
|
return parts.length > 1 ? parts.pop().toUpperCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换播放状态
|
||||||
|
const togglePlay = () => {
|
||||||
|
isPlaying.value = !isPlaying.value
|
||||||
|
if (props.extra.is_uploading && props.extra.upload_id) {
|
||||||
|
const action = isPlaying.value ? 'pauseUpload' : 'resumeUpload'
|
||||||
|
uploadsStore[action](props.extra.upload_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算SVG圆环进度条的参数
|
||||||
|
const radius = 9
|
||||||
|
const circumference = computed(() => 2 * Math.PI * radius)
|
||||||
|
const strokeDashoffset = computed(() =>
|
||||||
|
circumference.value * (1 - (props.extra.percentage || 0) / 100)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 处理文件点击事件
|
||||||
|
const handleClick = () => {
|
||||||
|
if(!props.extra.is_uploading){
|
||||||
|
window.open(
|
||||||
|
`${window.location.origin}/office?url=${props.extra.path}`,
|
||||||
|
'_blank',
|
||||||
|
'width=1200,height=900,left=200,top=200,toolbar=no,menubar=no,scrollbars=yes,resizable=yes,location=no,status=no'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFileWithProgress(resourceUrl, filename) {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.style.display = 'none';
|
||||||
|
iframe.src = resourceUrl;
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 处理下载事件
|
||||||
|
const handleDownload = () => {
|
||||||
|
downloadFileWithProgress(props.extra.path,props.extra.name)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="file-message">
|
<div class="file-message flex flex-col" @click="handleClick">
|
||||||
<div class="main">
|
<!-- 文件头部信息 -->
|
||||||
<div class="ext">{{ getFileNameSuffix(extra.name) }}</div>
|
<div class="file-header">
|
||||||
<div class="file-box">
|
<!-- 文件名 -->
|
||||||
<p class="info">
|
<div class="file-name">{{ extra.name }}</div>
|
||||||
<span class="name">{{ extra.name }}</span>
|
<!-- 文件图标区域 -->
|
||||||
<span class="size">({{ fileFormatSize(extra.size) }})</span>
|
<div class="file-icon-container">
|
||||||
</p>
|
<img class="file-icon" :src="fileInfo.icon" alt="文件图标">
|
||||||
<p class="notice">文件已成功发送, 文件助手永久保存</p>
|
|
||||||
|
<!-- 上传进度圆环 - 上传状态 -->
|
||||||
|
<div v-if="extra.is_uploading&&extra.percentage!==-1" class="progress-overlay">
|
||||||
|
<div class="circle-progress-container" @click.stop="togglePlay">
|
||||||
|
<svg class="circle-progress" width="20" height="20" viewBox="0 0 20 20">
|
||||||
|
<!-- 底色圆环 -->
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r="9"
|
||||||
|
fill="transparent"
|
||||||
|
stroke="#EEEEEE"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<!-- 进度圆环 -->
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r="9"
|
||||||
|
fill="transparent"
|
||||||
|
:stroke="fileInfo.color"
|
||||||
|
stroke-width="2"
|
||||||
|
:stroke-dasharray="circumference"
|
||||||
|
:stroke-dashoffset="strokeDashoffset"
|
||||||
|
transform="rotate(-90 10 10)"
|
||||||
|
class="progress-circle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 暂停/播放图标 -->
|
||||||
|
|
||||||
|
<g v-if="isPlaying" class="play-icon">
|
||||||
|
<rect x="6" y="6" width="8" height="8" :fill="fileInfo.color" />
|
||||||
|
</g>
|
||||||
|
<g v-else class="pause-icon">
|
||||||
|
<rect x="7" y="5" width="2" height="10" :fill="fileInfo.color" />
|
||||||
|
<rect x="11" y="5" width="2" height="10" :fill="fileInfo.color" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<!-- 文件大小信息 -->
|
||||||
<a @click="download(data.msg_id)">下载</a>
|
<div class="flex justify-between items-center grow-1">
|
||||||
<a>在线预览</a>
|
<div class="file-size">{{ fileFormatSize(extra.size) }}</div>
|
||||||
|
<div class="flex items-center" v-if="!extra.is_uploading">
|
||||||
|
<div class="flex items-center" @click.stop="handleDownload"> <img class="w-11.7px h-11.74px mr-7px" src="@/assets/image/dofd.png" alt=""> <span class="text-12px text-#46299D">下载</span></div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.file-message {
|
.file-message {
|
||||||
width: 250px;
|
width: 243px;
|
||||||
min-height: 85px;
|
background-color: #fff;
|
||||||
padding: 10px;
|
height: 110px;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--im-message-border-color);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.main {
|
.file-header {
|
||||||
height: 45px;
|
display: flex;
|
||||||
display: flex;
|
padding: 14px 5px 14px 0;
|
||||||
flex-direction: row;
|
justify-content: space-between;
|
||||||
margin-top: 5px;
|
width: 100%;
|
||||||
|
border-bottom: 1px solid #EEEEEE;
|
||||||
|
}
|
||||||
|
|
||||||
.ext {
|
.file-name {
|
||||||
display: flex;
|
height: 50px;
|
||||||
justify-content: center;
|
color: #1A1A1A;
|
||||||
align-items: center;
|
font-size: 14px;
|
||||||
width: 45px;
|
word-break: break-word;
|
||||||
height: 45px;
|
overflow: hidden;
|
||||||
color: #ffffff;
|
text-overflow: ellipsis;
|
||||||
background: #49a4ff;
|
display: -webkit-box;
|
||||||
border-radius: 5px;
|
-webkit-line-clamp: 2;
|
||||||
font-size: 12px;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-box {
|
.file-icon-container {
|
||||||
flex: 1 1;
|
height: 48px;
|
||||||
height: 45px;
|
position: relative;
|
||||||
margin-left: 10px;
|
}
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.info {
|
.file-icon {
|
||||||
display: flex;
|
width: 48px;
|
||||||
justify-content: space-between;
|
height: 48px;
|
||||||
align-items: center;
|
}
|
||||||
overflow: hidden;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.name {
|
.progress-overlay {
|
||||||
flex: 1 auto;
|
background-color: #fff;
|
||||||
white-space: nowrap;
|
position: absolute;
|
||||||
overflow: hidden;
|
top: 6px;
|
||||||
text-overflow: ellipsis;
|
left: 11px;
|
||||||
}
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.size {
|
.file-size {
|
||||||
font-size: 12px;
|
color: #747474;
|
||||||
color: #cac6c6;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice {
|
.circle-progress-container {
|
||||||
height: 25px;
|
width: 20px;
|
||||||
line-height: 25px;
|
height: 20px;
|
||||||
font-size: 12px;
|
position: relative;
|
||||||
color: #929191;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
}
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
.circle-progress {
|
||||||
height: 30px;
|
transform: rotate(-90deg);
|
||||||
line-height: 37px;
|
transform-origin: center;
|
||||||
text-align: right;
|
}
|
||||||
font-size: 12px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
margin-top: 10px;
|
|
||||||
|
|
||||||
a {
|
.progress-circle {
|
||||||
margin: 0 3px;
|
transition: stroke-dashoffset 0.3s ease;
|
||||||
user-select: none;
|
}
|
||||||
cursor: pointer;
|
|
||||||
color: var(--im-text-color);
|
|
||||||
|
|
||||||
&:hover {
|
.pause-icon, .play-icon {
|
||||||
color: royalblue;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
.pause-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -33,7 +33,7 @@ const onClick = () => {
|
|||||||
<span>转发:聊天会话记录 ({{ extra.msg_ids.length }}条)</span>
|
<span>转发:聊天会话记录 ({{ extra.msg_ids.length }}条)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ForwardRecord v-if="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" />
|
<ForwardRecord v-model:show="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ const img = (src: string, width = 200) => {
|
|||||||
:class="{ left: data.float === 'left' }"
|
:class="{ left: data.float === 'left' }"
|
||||||
:style="img(extra.url, 350)"
|
:style="img(extra.url, 350)"
|
||||||
>
|
>
|
||||||
<n-image :src="extra.url" />
|
<n-image class="h-149px" :src="extra.url" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@ -44,9 +44,7 @@ const img = (src: string, width = 200) => {
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: var(--im-message-left-bg-color);
|
background: var(--im-message-left-bg-color);
|
||||||
min-width: 30px;
|
height:149px
|
||||||
min-height: 30px;
|
|
||||||
|
|
||||||
&.left {
|
&.left {
|
||||||
background: var(--im-message-right-bg-color);
|
background: var(--im-message-right-bg-color);
|
||||||
}
|
}
|
||||||
|
95
src/components/talk/message/LinkMessage.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { textReplaceEmoji } from '@/utils/emojis'
|
||||||
|
import { textReplaceLink, textReplaceMention } from '@/utils/strings'
|
||||||
|
import { ITalkRecordExtraText, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
extra: ITalkRecordExtraText
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: boolean
|
||||||
|
source?: 'panel' | 'forward' | 'history'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const float = props.data.float
|
||||||
|
|
||||||
|
let textContent = props.extra?.content || ''
|
||||||
|
|
||||||
|
textContent = textReplaceLink(textContent)
|
||||||
|
|
||||||
|
if (props.data.talk_type == 2) {
|
||||||
|
textContent = textReplaceMention(textContent, '#462AA0')
|
||||||
|
}
|
||||||
|
|
||||||
|
textContent = textReplaceEmoji(textContent)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="im-message-text"
|
||||||
|
:class="{
|
||||||
|
left: float == 'left',
|
||||||
|
right: float == 'right',
|
||||||
|
maxwidth: maxWidth,
|
||||||
|
'radius-reset': source != 'panel',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<pre v-html="textContent" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-text {
|
||||||
|
min-width: 40rpx;
|
||||||
|
min-height: 40rpx;
|
||||||
|
padding: 22rpx 30rpx;
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 0 16rpx 16rpx 16rpx;
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
background-color: #46299d;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 16rpx 0 16rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.maxwidth {
|
||||||
|
max-width: 486rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.radius-reset {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
|
||||||
|
line-height: 44rpx;
|
||||||
|
|
||||||
|
:deep(.emoji) {
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin: 0 10rpx;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
color: #2196f3;
|
||||||
|
text-decoration: revert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,7 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { formatTime } from '@/utils/datetime'
|
import { formatTime } from '@/utils/datetime'
|
||||||
|
import { bus } from '@/utils/event-bus'
|
||||||
|
import { EditorConst } from '@/constant/event-bus'
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
login_uid: {
|
login_uid: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
@ -21,13 +23,30 @@ defineProps({
|
|||||||
datetime: {
|
datetime: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const onRevoke = () => {
|
||||||
|
// 只处理文本消息
|
||||||
|
if (props.data.msg_type === 1 && props.data.extra?.content) {
|
||||||
|
// 通过事件总线发送编辑消息事件
|
||||||
|
bus.emit(EditorConst.Edit, {
|
||||||
|
content: props.data.extra.content
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="im-message-revoke">
|
<div class="im-message-revoke">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<span v-if="login_uid == user_id"> 你撤回了一条消息 | {{ formatTime(datetime) }} </span>
|
<div v-if="login_uid === user_id">
|
||||||
|
<span> 你撤回了一条消息 | {{ formatTime(datetime) }} </span>
|
||||||
|
<n-button @click="onRevoke" v-if="data.msg_type === 1&&data.extra?.content" text class="text-#46299D text-11px">重新编辑</n-button>
|
||||||
|
</div>
|
||||||
<span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span>
|
<span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
"{{ nickname }}" 撤回了一条消息 |
|
"{{ nickname }}" 撤回了一条消息 |
|
||||||
|
@ -17,7 +17,7 @@ let textContent = props.extra?.content || ''
|
|||||||
textContent = textReplaceLink(textContent)
|
textContent = textReplaceLink(textContent)
|
||||||
|
|
||||||
if (props.data.talk_type == 2) {
|
if (props.data.talk_type == 2) {
|
||||||
textContent = textReplaceMention(textContent, '#1890ff')
|
textContent = textReplaceMention(textContent, '#462AA0')
|
||||||
}
|
}
|
||||||
|
|
||||||
textContent = textReplaceEmoji(textContent)
|
textContent = textReplaceEmoji(textContent)
|
||||||
@ -43,9 +43,9 @@ textContent = textReplaceEmoji(textContent)
|
|||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
color: var(--im-message-left-text-color);
|
color: var(--im-message-left-text-color);
|
||||||
background: var(--im-message-left-bg-color);
|
background: #F4F4FC;
|
||||||
border-radius: 0px 10px 10px 10px;
|
border-radius: 0px 10px 10px 10px;
|
||||||
|
font-size: 14px;
|
||||||
&.right {
|
&.right {
|
||||||
background-color: var(--im-message-right-bg-color);
|
background-color: var(--im-message-right-bg-color);
|
||||||
color: var(--im-message-right-text-color);
|
color: var(--im-message-right-text-color);
|
||||||
@ -71,6 +71,8 @@ textContent = textReplaceEmoji(textContent)
|
|||||||
line-height: 25px;
|
line-height: 25px;
|
||||||
|
|
||||||
:deep(.emoji) {
|
:deep(.emoji) {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
vertical-align: text-bottom;
|
vertical-align: text-bottom;
|
||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import 'xgplayer/dist/index.min.css'
|
import 'xgplayer/dist/index.min.css'
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick, watch } from 'vue'
|
||||||
import { NImage, NModal, NCard } from 'naive-ui'
|
import { NImage, NModal, NCard, NProgress, NPopconfirm } from 'naive-ui'
|
||||||
import { Play, Close } from '@icon-park/vue-next'
|
import { Play, Close, Pause, Right, Attention } from '@icon-park/vue-next'
|
||||||
import { getImageInfo } from '@/utils/functions'
|
import { getImageInfo } from '@/utils/functions'
|
||||||
|
import {PauseOutline} from '@vicons/ionicons5'
|
||||||
import Player from 'xgplayer'
|
import Player from 'xgplayer'
|
||||||
import { ITalkRecordExtraVideo, ITalkRecord } from '@/types/chat'
|
import { ITalkRecordExtraVideo, ITalkRecord } from '@/types/chat'
|
||||||
|
import { useUploadsStore } from '@/store'
|
||||||
|
// @ts-ignore
|
||||||
|
const message = window.$message
|
||||||
|
|
||||||
|
const uploadsStore = useUploadsStore()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
extra: ITalkRecordExtraVideo
|
extra: ITalkRecordExtraVideo
|
||||||
@ -13,35 +19,70 @@ const props = defineProps<{
|
|||||||
maxWidth?: Boolean
|
maxWidth?: Boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const img = (src: string, width = 200) => {
|
// const img = (src: string, width = 200) => {
|
||||||
const info: any = getImageInfo(src)
|
// const info: any = getImageInfo(src)
|
||||||
|
|
||||||
if (info.width == 0 || info.height == 0) {
|
// if (info.width == 0 || info.height == 0) {
|
||||||
return {}
|
// return {}
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (info.height > 300) {
|
// if (info.height > 300) {
|
||||||
return {
|
// return {
|
||||||
height: '300px'
|
// height: '300px'
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (info.width < width) {
|
||||||
|
// return {
|
||||||
|
// width: `${info.width}px`,
|
||||||
|
// height: `${info.height}px`
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// width: width + 'px',
|
||||||
|
// height: info.height / (info.width / width) + 'px'
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const isPaused = ref(false)
|
||||||
|
const uploadFailed = ref(false)
|
||||||
|
|
||||||
|
// 查找上传项并检查状态
|
||||||
|
const updatePauseStatus = () => {
|
||||||
|
if (props.extra.is_uploading && props.extra.upload_id) {
|
||||||
|
// 使用新的查找方法
|
||||||
|
const item = uploadsStore.findItemByClientId(props.extra.upload_id)
|
||||||
|
|
||||||
|
if (item && item.is_paused !== undefined) {
|
||||||
|
isPaused.value = item.is_paused
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.width < width) {
|
|
||||||
return {
|
|
||||||
width: `${info.width}px`,
|
|
||||||
height: `${info.height}px`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: width + 'px',
|
|
||||||
height: info.height / (info.width / width) + 'px'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const open = ref(false)
|
// 初始化时检查状态
|
||||||
|
updatePauseStatus()
|
||||||
|
|
||||||
|
// // 监听关键道具变化
|
||||||
|
// watch(() => props.extra.percentage, (newVal: number | undefined) => {
|
||||||
|
// // 确保进度更新时 UI 也实时更新
|
||||||
|
// // 检测上传失败状态 (-1表示上传失败)
|
||||||
|
// if (newVal === -1) {
|
||||||
|
// uploadFailed.value = true
|
||||||
|
// // 显示上传失败提示
|
||||||
|
// message.error('视频发送失败,请点击红色感叹号重试')
|
||||||
|
// } else if (newVal !== undefined && newVal > 0) {
|
||||||
|
// uploadFailed.value = false
|
||||||
|
// }
|
||||||
|
// }, { immediate: true })
|
||||||
|
|
||||||
async function onPlay() {
|
async function onPlay() {
|
||||||
|
// 如果视频正在上传,不执行播放操作
|
||||||
|
if (props.extra.is_uploading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
open.value = true
|
open.value = true
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@ -54,18 +95,86 @@ async function onPlay() {
|
|||||||
lang: 'zh-cn'
|
lang: 'zh-cn'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 暂停上传
|
||||||
|
function pauseUpload(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (props.extra.is_uploading && props.extra.upload_id) {
|
||||||
|
uploadsStore.pauseUpload(props.extra.upload_id)
|
||||||
|
isPaused.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续上传
|
||||||
|
function resumeUpload(e) {
|
||||||
|
console.log('resumeUpload')
|
||||||
|
e.stopPropagation()
|
||||||
|
if (props.extra.is_uploading && props.extra.upload_id) {
|
||||||
|
uploadsStore.resumeUpload(props.extra.upload_id)
|
||||||
|
isPaused.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新上传视频
|
||||||
|
// function retryUpload(e) {
|
||||||
|
// e.stopPropagation()
|
||||||
|
// if (props.extra.upload_id) {
|
||||||
|
// // 重置失败状态
|
||||||
|
// uploadFailed.value = false
|
||||||
|
|
||||||
|
// // 恢复上传
|
||||||
|
// uploadsStore.resumeUpload(props.extra.upload_id)
|
||||||
|
// message.success('正在重新上传视频...')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="im-message-video"
|
class="im-message-video"
|
||||||
:class="{ left: data.float === 'left' }"
|
:class="{ left: data.float === 'left' }"
|
||||||
:style="img(extra.cover, 350)"
|
|
||||||
@click="onPlay"
|
@click="onPlay"
|
||||||
>
|
>
|
||||||
<n-image :src="extra.cover" preview-disabled />
|
|
||||||
|
<!-- <n-image :src="extra.cover" preview-disabled /> -->
|
||||||
|
<video :src="props.extra.url" :controls="false"></video>
|
||||||
|
<!-- 上传进度时的黑色半透明蒙层 -->
|
||||||
|
<div v-if="extra.is_uploading && !uploadFailed" class="upload-mask"></div>
|
||||||
|
<!-- 上传进度显示 -->
|
||||||
|
<div v-if="extra.is_uploading && !uploadFailed" class="upload-progress">
|
||||||
|
<n-progress
|
||||||
|
|
||||||
|
type="circle"
|
||||||
|
:percentage="Math.round(extra.percentage || 0)"
|
||||||
|
:show-indicator="false"
|
||||||
|
:stroke-width="6"
|
||||||
|
color="#fff"
|
||||||
|
rail-color="#E3E3E3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 暂停/继续按钮移到圆圈内部 -->
|
||||||
|
<div class="upload-control" @click.stop>
|
||||||
|
<n-icon
|
||||||
|
v-if="!isPaused"
|
||||||
|
class="control-btn"
|
||||||
|
:component="PauseOutline"
|
||||||
|
size="20"
|
||||||
|
@click="pauseUpload"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-15px h-15px bg-#fff rounded-4px" @click="resumeUpload" >
|
||||||
|
|
||||||
<div class="btn-video">
|
</div>
|
||||||
<n-icon :component="Play" size="36" />
|
<!-- <n-icon
|
||||||
|
v-else
|
||||||
|
class="control-btn"
|
||||||
|
:component="Right"
|
||||||
|
size="20"
|
||||||
|
@click="resumeUpload"
|
||||||
|
/> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 播放按钮,仅在视频不是上传状态且未失败时显示 -->
|
||||||
|
<div v-if="!extra.is_uploading && !uploadFailed" class="btn-video">
|
||||||
|
<n-icon :component="Play" size="40" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-modal v-model:show="open">
|
<n-modal v-model:show="open">
|
||||||
@ -92,23 +201,25 @@ async function onPlay() {
|
|||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height:149px;
|
||||||
|
width: 225px;
|
||||||
&.left {
|
&.left {
|
||||||
background: var(--im-message-right-bg-color);
|
background: var(--im-message-right-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.n-image img) {
|
video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
object-fit: cover;
|
||||||
|
background-color: #333; /* 添加背景色,避免默认显示为灰色 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-video {
|
.btn-video {
|
||||||
width: 30px;
|
left: 50%;
|
||||||
height: 20px;
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: calc(50% - 15px);
|
|
||||||
top: calc(50% - 10px);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
@ -134,4 +245,66 @@ async function onPlay() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-mask {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.upload-control {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
color: white;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上传失败样式 */
|
||||||
|
.upload-failed {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.failed-icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -121,7 +121,7 @@ onMounted(() => {
|
|||||||
:height="5"
|
:height="5"
|
||||||
:show-indicator="false"
|
:show-indicator="false"
|
||||||
:percentage="parseInt(option.progress)"
|
:percentage="parseInt(option.progress)"
|
||||||
color="#1890ff"
|
color="#462AA0"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
25
src/components/talk/message/system/SysGroupAdminMessage.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import './sys-message.less'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
extra: Object,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="im-message-sys-text">
|
||||||
|
<div class="sys-text">
|
||||||
|
|
||||||
|
<template v-for="(user, index) in extra.members" :key="index">
|
||||||
|
<a @click="showUserInfoModal(user.erp_user_id,user.user_id)">{{ user.nickname }}</a>
|
||||||
|
<em v-show="index < extra.members.length - 1">、</em>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span>已成为管理员</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -13,6 +13,7 @@ const { showUserInfoModal } = useInject()
|
|||||||
<template>
|
<template>
|
||||||
<div class="im-message-sys-text">
|
<div class="im-message-sys-text">
|
||||||
<div class="sys-text">
|
<div class="sys-text">
|
||||||
|
|
||||||
<a @click="showUserInfoModal(extra.owner_id)">
|
<a @click="showUserInfoModal(extra.owner_id)">
|
||||||
{{ extra.owner_name }}
|
{{ extra.owner_name }}
|
||||||
</a>
|
</a>
|
||||||
|
19
src/components/talk/message/system/SysGroupDismissed.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
import './sys-message.less'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
extra: Object,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="im-message-sys-text">
|
||||||
|
<div class="sys-text">
|
||||||
|
<span>{{ extra.content }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import './sys-message.less'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
extra: Object,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="im-message-sys-text">
|
||||||
|
<div class="sys-text">
|
||||||
|
<a @click="showUserInfoModal(data.user_id)">
|
||||||
|
<!-- {{ data.nickname }} -->
|
||||||
|
管理员
|
||||||
|
</a>
|
||||||
|
<!-- <span>修改群名为</span>
|
||||||
|
<span>"{{ extra.group_name }}"</span> -->
|
||||||
|
<span>修改了群信息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -24,7 +24,7 @@ const { showUserInfoModal } = useInject()
|
|||||||
<em v-show="index < extra.members.length - 1">、</em>
|
<em v-show="index < extra.members.length - 1">、</em>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<span>踢出群聊</span>
|
<span>移出群聊</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
import './sys-message.less'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
extra: Object,
|
||||||
|
data: Object,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="im-message-sys-text">
|
||||||
|
<div class="sys-text">
|
||||||
|
<template v-for="(user, index) in extra?.members" :key="index">
|
||||||
|
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
|
||||||
|
<em v-show="index < extra.members.length - 1">、</em>
|
||||||
|
</template>
|
||||||
|
<span>已离开此群</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -28,7 +28,7 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #1890ff;
|
color: #462AA0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { NModal, NInput, NScrollbar, NCheckbox, NTabs, NTab } from 'naive-ui'
|
import { ServeGetTalkList } from '@/api/chat.js'
|
||||||
import { Search, Delete } from '@icon-park/vue-next'
|
|
||||||
import { ServeGetContacts } from '@/api/contact'
|
|
||||||
import { ServeGetGroups } from '@/api/group'
|
import { ServeGetGroups } from '@/api/group'
|
||||||
|
import XNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
|
||||||
const emit = defineEmits(['close', 'on-submit'])
|
const emit = defineEmits(['close', 'on-submit'])
|
||||||
|
import { CloseCircle } from '@vicons/ionicons5'
|
||||||
interface Item {
|
interface Item {
|
||||||
id: number
|
id: number
|
||||||
type: number
|
type: number
|
||||||
@ -17,16 +15,18 @@ interface Item {
|
|||||||
keyword: string
|
keyword: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabsIndex = ref<number>(1)
|
const isShowBox = defineModel('show')
|
||||||
const isShowBox = ref(true)
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const items = ref<Item[]>([])
|
const items = ref<Item[]>([])
|
||||||
const keywords = ref('')
|
const keywords = ref('')
|
||||||
const loadGroupStatus = ref(false)
|
const loadGroupStatus = ref(false)
|
||||||
|
defineProps<{
|
||||||
|
forwardMode: number
|
||||||
|
}>()
|
||||||
|
// 搜索过滤器:不再按类型过滤,将好友和群组融合在一起
|
||||||
const searchFilter = computed(() => {
|
const searchFilter = computed(() => {
|
||||||
return items.value.filter((item: Item) => {
|
return items.value.filter((item: Item) => {
|
||||||
return tabsIndex.value == item.type && item.keyword.match(keywords.value) != null
|
return item.name.toLowerCase().includes(keywords.value.toLowerCase())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -40,23 +40,19 @@ const isCanSubmit = computed(() => {
|
|||||||
|
|
||||||
const onLoad = () => {
|
const onLoad = () => {
|
||||||
onLoadContact()
|
onLoadContact()
|
||||||
|
// onLoadGroup()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLoadContact = () => {
|
const onLoadContact = () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
ServeGetContacts()
|
ServeGetTalkList()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.code == 200) {
|
if (res.code == 200) {
|
||||||
let list = res.data.items || []
|
let list = res.data.items || []
|
||||||
|
|
||||||
items.value = list.map((item: any) => {
|
items.value = list.map((item: any) => {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
...item,
|
||||||
avatar: item.avatar,
|
|
||||||
type: 1,
|
|
||||||
name: item.remark || item.nickname,
|
|
||||||
keyword: item.remark + item.nickname,
|
|
||||||
remark: item.remark,
|
|
||||||
checked: false
|
checked: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -67,40 +63,48 @@ const onLoadContact = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLoadGroup = async () => {
|
// const onLoadGroup = async () => {
|
||||||
if (loadGroupStatus.value) {
|
// if (loadGroupStatus.value) {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
|
|
||||||
loading.value = true
|
// loading.value = true
|
||||||
let { code, data } = await ServeGetGroups()
|
// let { code, data } = await ServeGetGroups()
|
||||||
if (code != 200) {
|
// if (code != 200) {
|
||||||
return
|
// loading.value = false
|
||||||
}
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
let list = data.items.map((item: any) => {
|
// let list = data.items.map((item: any) => {
|
||||||
return {
|
// return {
|
||||||
id: item.id,
|
// id: item.id,
|
||||||
avatar: item.avatar,
|
// avatar: item.avatar,
|
||||||
type: 2,
|
// type: 2,
|
||||||
name: item.group_name,
|
// name: item.group_name,
|
||||||
keyword: item.group_name,
|
// keyword: item.group_name,
|
||||||
remark: '',
|
// remark: '',
|
||||||
checked: false
|
// checked: false
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
|
|
||||||
items.value.push(...list)
|
// items.value.push(...list)
|
||||||
|
|
||||||
loading.value = false
|
// loading.value = false
|
||||||
loadGroupStatus.value = true
|
// loadGroupStatus.value = true
|
||||||
}
|
// }
|
||||||
|
|
||||||
const onMaskClick = () => {
|
const onMaskClick = () => {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTriggerContact = (item: any) => {
|
const onTriggerContact = (item: any) => {
|
||||||
|
// 如果是单选模式,先取消所有选中
|
||||||
|
if (selectType.value === 1) {
|
||||||
|
items.value.forEach(contact => {
|
||||||
|
contact.checked = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let data = items.value.find((val: any) => val.id === item.id)
|
let data = items.value.find((val: any) => val.id === item.id)
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
@ -108,230 +112,152 @@ const onTriggerContact = (item: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onRemoveContact = (item: any) => {
|
||||||
|
let data = items.value.find((val: any) => val.id === item.id)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
data.checked = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
isShowBox.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
let data = checkedFilter.value.map((item: any) => {
|
let data = checkedFilter.value.map((item: any) => {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
receiver_id: item.receiver_id,
|
||||||
type: item.type
|
talk_type: item.talk_type
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
console.log('data', data);
|
||||||
|
console.log('checkedFilter.value', checkedFilter.value);
|
||||||
emit('on-submit', data)
|
emit('on-submit', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTabs = (value: number) => {
|
// 1 单选 2 多选
|
||||||
tabsIndex.value = value
|
const selectType = ref(1)
|
||||||
if (value == 2) {
|
const changeSelectType = () => {
|
||||||
onLoadGroup()
|
selectType.value = selectType.value == 1 ? 2 : 1
|
||||||
}
|
|
||||||
|
// 切换选择模式时清空已选择的联系人
|
||||||
|
items.value.forEach(item => {
|
||||||
|
item.checked = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad()
|
watch(()=>{
|
||||||
|
return isShowBox.value
|
||||||
|
},(newVal)=>{
|
||||||
|
if(newVal){
|
||||||
|
onLoad()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal
|
<x-n-modal v-model:show="isShowBox" :title="forwardMode === 2 ? '合并转发' : '逐条转发'" style="width: 997px; height: 740px;background-color: #F9F9FD"
|
||||||
v-model:show="isShowBox"
|
:on-after-leave="onMaskClick" content-style="display: flex; justify-content: center; align-items: center;">
|
||||||
preset="card"
|
<div class="w-927px h-627px bg-#fff rounded-3px px-35px py-20px">
|
||||||
title="选择联系人"
|
<div class="flex items-center justify-between mb-28px">
|
||||||
class="modal-radius"
|
<div class="text-#333639">搜索</div>
|
||||||
style="max-width: 650px; height: 550px"
|
<div class="w-779px h-34px">
|
||||||
:on-after-leave="onMaskClick"
|
<n-input v-model:value="keywords" type="text" clearable placeholder="请输入">
|
||||||
:segmented="{
|
|
||||||
content: true,
|
</n-input>
|
||||||
footer: true
|
|
||||||
}"
|
|
||||||
:content-style="{
|
|
||||||
padding: 0
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<section class="el-container launch-box">
|
|
||||||
<aside class="el-aside bdr-r" style="width: 240px">
|
|
||||||
<section class="el-container is-vertical height100">
|
|
||||||
<header class="el-header tabs">
|
|
||||||
<n-tabs type="line" justify-content="space-around" @update:value="onTabs">
|
|
||||||
<n-tab name="1"> 好友 </n-tab>
|
|
||||||
<n-tab name="2"> 群聊 </n-tab>
|
|
||||||
<!-- <n-tab name="企业"> 企业 </n-tab> -->
|
|
||||||
</n-tabs>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<header class="el-header sub-header">
|
|
||||||
<n-input placeholder="搜索" v-model:value="keywords" clearable size="small">
|
|
||||||
<template #prefix>
|
|
||||||
<n-icon :component="Search" />
|
|
||||||
</template>
|
|
||||||
</n-input>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="el-main" v-loading="loading" loading-text="加载中...">
|
|
||||||
<n-scrollbar>
|
|
||||||
<div class="friend-items">
|
|
||||||
<div
|
|
||||||
class="friend-item pointer"
|
|
||||||
v-for="item in searchFilter"
|
|
||||||
:key="item.id"
|
|
||||||
@click="onTriggerContact(item)"
|
|
||||||
>
|
|
||||||
<div class="avatar">
|
|
||||||
<im-avatar
|
|
||||||
class="pointer"
|
|
||||||
:src="item.avatar"
|
|
||||||
:size="25"
|
|
||||||
:username="item.remark || item.name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<span class="text-ellipsis">{{ item.remark || item.name }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="checkbox">
|
|
||||||
<n-checkbox size="small" :checked="item.checked" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-scrollbar>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="el-main">
|
|
||||||
<section class="el-container is-vertical height100">
|
|
||||||
<main class="el-main o-hidden">
|
|
||||||
<n-scrollbar class="friend-items">
|
|
||||||
<div class="friend-items">
|
|
||||||
<div v-show="!checkedFilter.length" style="padding-top: 100px">
|
|
||||||
<n-empty size="200" description="暂无数据">
|
|
||||||
<template #icon>
|
|
||||||
<img src="@/assets/image/no-data.svg" alt="" />
|
|
||||||
</template>
|
|
||||||
</n-empty>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="friend-item pointer"
|
|
||||||
v-for="item in checkedFilter"
|
|
||||||
:key="item.id"
|
|
||||||
@click="onTriggerContact(item)"
|
|
||||||
>
|
|
||||||
<div class="avatar">
|
|
||||||
<im-avatar
|
|
||||||
class="pointer"
|
|
||||||
:src="item.avatar"
|
|
||||||
:size="25"
|
|
||||||
:username="item.remark || item.name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<span class="text-ellipsis">
|
|
||||||
{{ item.remark || item.name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="item.type == 2" class="badge group">群</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="checkbox">
|
|
||||||
<n-icon :size="16" :component="Delete" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-scrollbar>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="footer">
|
|
||||||
<div>
|
|
||||||
<span>已选择({{ checkedFilter.length }})</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<n-button type="tertiary" @click="isShowBox = false"> 取消 </n-button>
|
|
||||||
<n-button type="primary" class="mt-l15" @click="onSubmit" :disabled="isCanSubmit">
|
|
||||||
确定
|
|
||||||
</n-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div class="flex justify-between">
|
||||||
</n-modal>
|
<div class="w-260px h-517px rounded-4px border-1px border-solid border-#E5E5E5 px-12px">
|
||||||
|
<div class="border-b-2px border-b-solid border-b-#FBFBFB h-35px flex items-center justify-end">
|
||||||
|
<n-button text color="#46299D" class="text-14px" @click="changeSelectType">
|
||||||
|
{{ selectType === 1 ? '多选' : '单选' }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<n-virtual-list v-if="!loading" style="max-height: 470px" :item-size="65" :items="searchFilter">
|
||||||
|
<template #default="{ item }">
|
||||||
|
<div class="flex items-center border-b-2px border-b-solid h-65px border-b-#FBFBFB"
|
||||||
|
@click="onTriggerContact(item)">
|
||||||
|
<div class="mr-22px">
|
||||||
|
<n-radio v-if="selectType === 1" :checked="item.checked" />
|
||||||
|
<n-checkbox v-else :checked="item.checked" />
|
||||||
|
</div>
|
||||||
|
<div class="mr-10px">
|
||||||
|
|
||||||
|
<avatarModule class="mr-10px" showGroupType :mode="item.talk_type"
|
||||||
|
:avatar="item.avatar"
|
||||||
|
:groupType="item.group_type"
|
||||||
|
:customStyle="{width:'42px',height:'42px'}"></avatarModule>
|
||||||
|
<!-- <n-image class="w-42px h-42px rounded-full" :src="item.avatar" /> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-ellipsis">{{ item.name }}</span>
|
||||||
|
<span v-if="item.type == 2" class="badge group ml-2">群</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-virtual-list>
|
||||||
|
<div v-else class="flex-center h-470px">
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-578px h-517px rounded-4px border-1px border-solid border-#E5E5E5 px-12px">
|
||||||
|
<div class="border-b-2px border-b-solid border-b-#FBFBFB h-35px flex items-center text-14px text-#000">
|
||||||
|
发送给
|
||||||
|
</div>
|
||||||
|
<div class="h-350px border-b-2px border-b-solid border-b-#FBFBFB">
|
||||||
|
<div v-if="checkedFilter.length > 0">
|
||||||
|
<n-virtual-list style="max-height: 350px" :item-size="65" :items="checkedFilter">
|
||||||
|
<template #default="{ item }">
|
||||||
|
<div class="flex items-center border-b-2px border-b-solid h-65px border-b-#FBFBFB pr-20px">
|
||||||
|
<div class="mr-10px">
|
||||||
|
<avatarModule class="mr-10px" showGroupType :mode="item.talk_type"
|
||||||
|
:avatar="item.avatar"
|
||||||
|
:groupType="item.group_type"
|
||||||
|
:customStyle="{width:'42px',height:'42px'}"></avatarModule>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-ellipsis">{{ item.name }}</span>
|
||||||
|
<span v-if="item.type == 2" class="badge group ml-2">群</span>
|
||||||
|
</div>
|
||||||
|
<n-button class="ml-auto" text color="#C7C7C9" @click="onRemoveContact(item)">
|
||||||
|
<n-icon :component="CloseCircle" size="18" />
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-virtual-list>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex-center h-350px">
|
||||||
|
<n-empty size="medium" description="暂无选择联系人">
|
||||||
|
<template #icon>
|
||||||
|
<img src="@/assets/image/no-data.svg" alt="" />
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center justify-center h-120px">
|
||||||
|
<div class="text-14px text-#999999 mb-23px">
|
||||||
|
<span>[{{ forwardMode === 2 ? '合并转发' : '逐条转发' }}]</span>
|
||||||
|
<span v-if="checkedFilter.length > 0">
|
||||||
|
{{
|
||||||
|
checkedFilter.length > 2
|
||||||
|
? checkedFilter.slice(0, 2).map(item => item.name).join('、') + ' 等'
|
||||||
|
: checkedFilter.map(item => item.name).join('、')
|
||||||
|
}}会话记录
|
||||||
|
</span>
|
||||||
|
<span v-else>请选择联系人</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
<n-button color="#C7C7C9" class="w-250px h-34px text-14px text-#fff mr-10px" @click="onCancel">取消</n-button>
|
||||||
|
<n-button color="#46299D" class="w-250px h-34px text-14px text-#fff"
|
||||||
|
@click="onSubmit" :disabled="isCanSubmit">发送</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
:deep(.n-divider__title) {
|
|
||||||
font-weight: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launch-box {
|
|
||||||
height: 410px;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.sub-header {
|
|
||||||
height: 50px;
|
|
||||||
padding: 10px 15px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.friend-items {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0 15px;
|
|
||||||
|
|
||||||
.friend-item {
|
|
||||||
height: 40px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
margin: 5px 0;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 30px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1 auto;
|
|
||||||
padding-left: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
justify-content: flex-start;
|
|
||||||
&:hover {
|
|
||||||
color: #409eff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 30px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
&.group {
|
|
||||||
color: #3370ff !important;
|
|
||||||
background-color: #e1eaff !important;
|
|
||||||
}
|
|
||||||
margin: 0 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, reactive } from 'vue'
|
import { ref, computed, reactive } from 'vue'
|
||||||
import { NIcon, NModal, NButton, NInput, NDropdown, NPopover } from 'naive-ui'
|
|
||||||
import { CloseOne, Male, Female, SendOne } from '@icon-park/vue-next'
|
import { CloseOne, Male, Female, SendOne } from '@icon-park/vue-next'
|
||||||
import { ServeSearchUser } from '@/api/contact'
|
import { ServeSearchUser } from '@/api/contact'
|
||||||
import { ServeCreateContact } from '@/api/contact'
|
import { ServeCreateContact } from '@/api/contact'
|
||||||
import { ServeContactGroupList, ServeContactMoveGroup, ServeEditContactRemark } from '@/api/contact'
|
import { ServeContactGroupList, ServeContactMoveGroup, ServeEditContactRemark } from '@/api/contact'
|
||||||
import { useTalkStore } from '@/store'
|
import { useTalkStore } from '@/store'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import xNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
|
||||||
|
import { NSkeleton } from 'naive-ui'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const talkStore = useTalkStore()
|
const talkStore = useTalkStore()
|
||||||
|
|
||||||
const emit = defineEmits(['update:show', 'update:uid', 'updateRemark'])
|
const emit = defineEmits(['update:show', 'update:uid', 'updateRemark'])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@ -21,12 +19,16 @@ const props = defineProps({
|
|||||||
uid: {
|
uid: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
|
},
|
||||||
|
euid: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const isOpenFrom = ref(false)
|
const isOpenFrom = ref(false)
|
||||||
const state: any = reactive({
|
const userInfo: any = ref({
|
||||||
id: 0,
|
id: 0,
|
||||||
avatar: '',
|
avatar: '',
|
||||||
gender: 0,
|
gender: 0,
|
||||||
@ -43,26 +45,26 @@ const editCardPopover: any = ref(false)
|
|||||||
const modelRemark = ref('')
|
const modelRemark = ref('')
|
||||||
|
|
||||||
const options = ref<any>([])
|
const options = ref<any>([])
|
||||||
const groupName = computed(() => {
|
// const groupName = computed(() => {
|
||||||
const item = options.value.find((item: any) => {
|
// const item = options.value.find((item: any) => {
|
||||||
return item.key == state.group_id
|
// return item.key == state.group_id
|
||||||
})
|
// })
|
||||||
|
|
||||||
if (item) {
|
// if (item) {
|
||||||
return item.label
|
// return item.label
|
||||||
}
|
// }
|
||||||
|
|
||||||
return '未设置分组'
|
// return '未设置分组'
|
||||||
})
|
// })
|
||||||
|
|
||||||
const onLoadData = () => {
|
const onLoadData = () => {
|
||||||
ServeSearchUser({
|
ServeSearchUser({
|
||||||
user_id: props.uid
|
erp_user_id: props.euid
|
||||||
}).then(({ code, data }) => {
|
}).then(({ code, data }) => {
|
||||||
if (code == 200) {
|
if (code == 200) {
|
||||||
Object.assign(state, data)
|
userInfo.value = data
|
||||||
|
|
||||||
modelRemark.value = state.remark
|
// modelRemark.value = state.remark
|
||||||
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
} else {
|
} else {
|
||||||
@ -70,15 +72,15 @@ const onLoadData = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ServeContactGroupList().then((res) => {
|
// ServeContactGroupList().then((res) => {
|
||||||
if (res.code == 200) {
|
// if (res.code == 200) {
|
||||||
let items = res.data.items || []
|
// let items = res.data.items || []
|
||||||
options.value = []
|
// options.value = []
|
||||||
for (const iter of items) {
|
// for (const iter of items) {
|
||||||
options.value.push({ label: iter.name, key: iter.id })
|
// options.value.push({ label: iter.name, key: iter.id })
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onToTalk = () => {
|
const onToTalk = () => {
|
||||||
@ -86,247 +88,190 @@ const onToTalk = () => {
|
|||||||
emit('update:show', false)
|
emit('update:show', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onJoinContact = () => {
|
// const onJoinContact = () => {
|
||||||
if (!state.text.length) {
|
// if (!state.text.length) {
|
||||||
return window['$message'].info('备注信息不能为空')
|
// return window['$message'].info('备注信息不能为空')
|
||||||
}
|
// }
|
||||||
|
|
||||||
ServeCreateContact({
|
// ServeCreateContact({
|
||||||
friend_id: props.uid,
|
// friend_id: props.uid,
|
||||||
remark: state.text
|
// remark: state.text
|
||||||
}).then((res) => {
|
// }).then((res) => {
|
||||||
if (res.code == 200) {
|
// if (res.code == 200) {
|
||||||
isOpenFrom.value = false
|
// isOpenFrom.value = false
|
||||||
window['$message'].success('申请发送成功')
|
// window['$message'].success('申请发送成功')
|
||||||
} else {
|
// } else {
|
||||||
window['$message'].error(res.message)
|
// window['$message'].error(res.message)
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
const onChangeRemark = () => {
|
// const onChangeRemark = () => {
|
||||||
ServeEditContactRemark({
|
// ServeEditContactRemark({
|
||||||
friend_id: props.uid,
|
// friend_id: props.uid,
|
||||||
remark: modelRemark.value
|
// remark: modelRemark.value
|
||||||
}).then(({ code, message }) => {
|
// }).then(({ code, message }) => {
|
||||||
if (code == 200) {
|
// if (code == 200) {
|
||||||
editCardPopover.value.setShow(false)
|
// editCardPopover.value.setShow(false)
|
||||||
window['$message'].success('备注成功')
|
// window['$message'].success('备注成功')
|
||||||
state.remark = modelRemark.value
|
// state.remark = modelRemark.value
|
||||||
|
|
||||||
emit('updateRemark', {
|
// emit('updateRemark', {
|
||||||
user_id: props.uid,
|
// user_id: props.uid,
|
||||||
remark: modelRemark.value
|
// remark: modelRemark.value
|
||||||
})
|
// })
|
||||||
} else {
|
// } else {
|
||||||
window['$message'].error(message)
|
// window['$message'].error(message)
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
const handleSelectGroup = (value) => {
|
// const handleSelectGroup = (value) => {
|
||||||
ServeContactMoveGroup({
|
// ServeContactMoveGroup({
|
||||||
user_id: props.uid,
|
// user_id: props.uid,
|
||||||
group_id: value
|
// group_id: value
|
||||||
}).then(({ code, message }) => {
|
// }).then(({ code, message }) => {
|
||||||
if (code == 200) {
|
// if (code == 200) {
|
||||||
state.group_id = value
|
// state.group_id = value
|
||||||
window['$message'].success('分组修改成功')
|
// window['$message'].success('分组修改成功')
|
||||||
} else {
|
// } else {
|
||||||
window['$message'].error(message)
|
// window['$message'].error(message)
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
const reset = () => {
|
// const reset = () => {
|
||||||
loading.value = true
|
// loading.value = true
|
||||||
|
|
||||||
Object.assign(state, {
|
// Object.assign(state, {
|
||||||
id: 0,
|
// id: 0,
|
||||||
avatar: '',
|
// avatar: '',
|
||||||
gender: 0,
|
// gender: 0,
|
||||||
mobile: '',
|
// mobile: '',
|
||||||
motto: '',
|
// motto: '',
|
||||||
nickname: '',
|
// nickname: '',
|
||||||
remark: '',
|
// remark: '',
|
||||||
email: '',
|
// email: '',
|
||||||
status: 1,
|
// status: 1,
|
||||||
text: ''
|
// text: ''
|
||||||
})
|
// })
|
||||||
|
|
||||||
isOpenFrom.value = false
|
// isOpenFrom.value = false
|
||||||
}
|
// }
|
||||||
|
|
||||||
const onUpdate = (value) => {
|
// const onUpdate = (value) => {
|
||||||
if (!value) {
|
// if (!value) {
|
||||||
setTimeout(reset, 100)
|
// setTimeout(reset, 100)
|
||||||
}
|
// }
|
||||||
|
|
||||||
emit('update:show', value)
|
// emit('update:show', value)
|
||||||
}
|
// }
|
||||||
|
|
||||||
const onAfterEnter = () => {
|
const onAfterEnter = () => {
|
||||||
onLoadData()
|
onLoadData()
|
||||||
}
|
}
|
||||||
|
const onAfterLeave = () => {
|
||||||
|
// loading.value = true
|
||||||
|
userInfo.value = {
|
||||||
|
id: 0,
|
||||||
|
avatar: '',
|
||||||
|
gender: 0,
|
||||||
|
mobile: '',
|
||||||
|
motto: '',
|
||||||
|
nickname: '',
|
||||||
|
remark: '',
|
||||||
|
email: '',
|
||||||
|
status: 1,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal :show="show" :on-update:show="onUpdate" :on-after-enter="onAfterEnter">
|
<x-n-modal content-style="padding:0;" :closable="false" class="w-311px min-h-445px" style="border-radius: 10px;overflow:hidden;" :show="show" :on-after-leave="onAfterLeave" :on-after-enter="onAfterEnter">
|
||||||
<div class="section" v-loading="loading">
|
<div class="section relative px-7px pt-82px pb-20px">
|
||||||
<section class="el-container container is-vertical">
|
<div class="absolute top-9px right-7px pointer z-10" @click="emit('update:show', false)">
|
||||||
<header class="el-header header">
|
<img class="w-20px h-20px" src="@/assets/image/close.png" alt="">
|
||||||
<im-avatar
|
</div>
|
||||||
class="avatar"
|
|
||||||
:size="100"
|
<template v-if="loading">
|
||||||
:src="state.avatar"
|
<div class="flex py-10px bg-#fff px-16px rounded-4px items-center mb-10px">
|
||||||
:username="state.remark || state.nickname"
|
<div class="w-59px h-59px rounded-8px mr-12px">
|
||||||
:font-size="30"
|
<n-skeleton height="59px" width="59px" />
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="gender" v-show="state.gender > 0">
|
|
||||||
<n-icon v-if="state.gender == 1" :component="Male" color="#508afe" />
|
|
||||||
<n-icon v-if="state.gender == 2" :component="Female" color="#ff5722" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
<div class="close" @click="onUpdate(false)">
|
<n-skeleton text style="width: 80%; margin-bottom: 5px;" />
|
||||||
<close-one theme="outline" size="22" fill="#fff" :strokeWidth="2" />
|
<n-skeleton text style="width: 60%;" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="nickname text-ellipsis">
|
<div class="bg-#fff rounded-4px mb-20px">
|
||||||
{{ state.remark || state.nickname || '未设置昵称' }}
|
<div class="flex px-15px py-9px" v-for="i in 6" :key="i">
|
||||||
|
<n-skeleton text style="width: 30%; margin-right: 10px;" />
|
||||||
|
<n-skeleton text style="width: 60%;" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
<div>
|
||||||
|
<n-skeleton text style="width: 100%; height: 42px; border-radius: 4px;" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex py-10px bg-#fff px-16px rounded-4px items-center mb-10px">
|
||||||
|
<div class="w-59px h-59px rounded-8px mr-12px overflow-hidden">
|
||||||
|
<n-image width="59" :src="userInfo.avatar" >
|
||||||
|
|
||||||
<main class="el-main main me-scrollbar me-scrollbar-thumb">
|
</n-image>
|
||||||
<div class="motto">
|
|
||||||
{{ state.motto || '编辑个签,展示我的独特态度。' }}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div class="infos">
|
<div class="text-#000 text-16px mb-5px">{{ userInfo.nickname }}</div>
|
||||||
<div class="info-item">
|
<div class="text-#ACACAC text-12px">工号:{{ userInfo.job_num }}</div>
|
||||||
<span class="name">工号 :</span>
|
|
||||||
<span class="text">{{ state.job_num}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="name">手机 :</span>
|
|
||||||
<span class="text">{{ state.mobile }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="name">昵称 :</span>
|
|
||||||
<span class="text text-ellipsis">{{ state.nickname || '-' }} </span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="name">性别 :</span>
|
|
||||||
<span class="text">{{
|
|
||||||
state.gender == 1 ? '男' : state.gender == 2 ? '女' : '未知'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item" v-if="state.friend_status == 2">
|
|
||||||
<span class="name">备注 :</span>
|
|
||||||
<n-popover trigger="click" placement="top-start" ref="editCardPopover">
|
|
||||||
<template #trigger>
|
|
||||||
<span class="text edit pointer text-ellipsis">
|
|
||||||
{{ state.remark || '未设置' }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #header> 设置备注 </template>
|
|
||||||
|
|
||||||
<div style="display: flex">
|
|
||||||
<n-input
|
|
||||||
type="text"
|
|
||||||
placeholder="请填写备注"
|
|
||||||
:autofocus="true"
|
|
||||||
maxlength="10"
|
|
||||||
v-model:value="modelRemark"
|
|
||||||
@keydown.enter="onChangeRemark"
|
|
||||||
/>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
text-color="#ffffff"
|
|
||||||
class="mt-l5"
|
|
||||||
@click="onChangeRemark"
|
|
||||||
>
|
|
||||||
确定
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</n-popover>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="name">邮箱 :</span>
|
|
||||||
<span class="text">{{ state.email || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item" v-if="state.friend_status == 2">
|
|
||||||
<span class="name">分组 :</span>
|
|
||||||
<n-dropdown
|
|
||||||
trigger="click"
|
|
||||||
placement="top-start"
|
|
||||||
:show-arrow="true"
|
|
||||||
:options="options"
|
|
||||||
@select="handleSelectGroup"
|
|
||||||
>
|
|
||||||
<span class="text edit pointer">{{ groupName }}</span>
|
|
||||||
</n-dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="name">入职时间 :</span>
|
|
||||||
<span class="text">{{ state.enter_date}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
<div class="bg-#fff rounded-4px mb-20px">
|
||||||
<footer v-if="state.friend_status == 2" class="el-footer footer bdr-t flex-center">
|
<div class="flex px-15px py-9px">
|
||||||
<n-button
|
<div class="text-#000 text-12px w-84px">公司别</div>
|
||||||
round
|
<div class="text-#747474 text-12px">{{ userInfo.company_name }}</div>
|
||||||
block
|
</div>
|
||||||
type="primary"
|
<div class="flex px-15px py-9px">
|
||||||
text-color="#ffffff"
|
<div class="text-#000 text-12px w-84px">主管</div>
|
||||||
@click="onToTalk"
|
<div class="text-#747474 text-12px">{{ userInfo.leaders?.map(x=>x.user_name)?.join(',') }}</div>
|
||||||
style="width: 91%"
|
</div>
|
||||||
>
|
<div class="flex px-15px py-9px">
|
||||||
<template #icon>
|
<div class="text-#000 text-12px w-84px">部门</div>
|
||||||
<n-icon :component="SendOne" />
|
<div class="text-#747474 text-12px">{{ userInfo.erp_dept_position?.map(x=>x.department_name)?.join(',') }}</div>
|
||||||
</template>
|
</div>
|
||||||
发送消息
|
<div class="flex px-15px py-9px">
|
||||||
|
<div class="text-#000 text-12px w-84px">手机号</div>
|
||||||
|
<div class="text-#747474 text-12px">{{ userInfo.tel_num }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex px-15px py-9px">
|
||||||
|
<div class="text-#000 text-12px w-84px">岗位</div>
|
||||||
|
<div class="text-#747474 text-12px">{{ userInfo.erp_dept_position?.map(x=>x.position_name)?.join(',') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex px-15px py-9px">
|
||||||
|
<div class="text-#000 text-12px w-84px">入职日期</div>
|
||||||
|
<div class="text-#747474 text-12px">{{ userInfo.enter_date }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<n-button block color="#EEE9F8" text-color="#46299D" @click="onToTalk">
|
||||||
|
<div class="flex items-center justify-center py-11px">
|
||||||
|
<img class="w-19.8px h-20px mr-15px" src="@/assets/image/faxi@2x.png" alt="">
|
||||||
|
<span>发送消息</span>
|
||||||
|
</div>
|
||||||
</n-button>
|
</n-button>
|
||||||
</footer>
|
</div>
|
||||||
|
</template>
|
||||||
<footer v-else-if="state.friend_status == 1" class="el-footer footer bdr-t flex-center">
|
|
||||||
<template v-if="isOpenFrom">
|
|
||||||
<n-input
|
|
||||||
type="text"
|
|
||||||
placeholder="请填写申请备注"
|
|
||||||
v-model:value="state.text"
|
|
||||||
@keydown.enter="onJoinContact"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-button type="primary" text-color="#ffffff" class="mt-l5" @click="onJoinContact">
|
|
||||||
确定
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
text-color="#ffffff"
|
|
||||||
block
|
|
||||||
round
|
|
||||||
style="width: 91%"
|
|
||||||
@click="isOpenFrom = true"
|
|
||||||
>
|
|
||||||
添加好友
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</n-modal>
|
</x-n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.section {
|
.section {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 360px;
|
background-image: url('@/assets/image/zu6254@2x.png');
|
||||||
height: 600px;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--im-bg-color);
|
background-color: var(--im-bg-color);
|
||||||
@ -336,7 +281,6 @@ const onAfterEnter = () => {
|
|||||||
height: 230px;
|
height: 230px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(to right, rgb(137, 104, 255), rgb(175, 152, 255));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -346,7 +290,6 @@ const onAfterEnter = () => {
|
|||||||
width: 150px;
|
width: 150px;
|
||||||
height: 150px;
|
height: 150px;
|
||||||
content: '';
|
content: '';
|
||||||
background: linear-gradient(to right, rgb(142, 110, 255), rgb(208, 195, 255));
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
142
src/components/x-naive-ui/README.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# @x-naive-ui 组件库
|
||||||
|
|
||||||
|
基于 Naive UI 的二次封装组件库,旨在提供更高层级的抽象和更便捷的使用方式,同时保持足够的灵活性。
|
||||||
|
|
||||||
|
@x-naive-ui 的设计理念是在易用性和灵活性之间找到平衡点,通过合理的默认值和可配置项,能够快速开发出高质量的页面,同时保留足够的扩展空间应对特殊需求。
|
||||||
|
|
||||||
|
**如发现文档与实际使用有出入或者不完善 可提交修改**
|
||||||
|
## 设计理念
|
||||||
|
|
||||||
|
### 1. 易用性与灵活性的平衡
|
||||||
|
|
||||||
|
- **约定优于配置**:提供合理的默认值,减少基础使用时的配置量
|
||||||
|
- **保持原有能力**:通过属性透传,保留 Naive UI 原组件的所有功能
|
||||||
|
- **渐进式配置**:简单场景可以快速使用,复杂场景仍可深度定制
|
||||||
|
|
||||||
|
### 2. 通用性与特殊性的权衡
|
||||||
|
|
||||||
|
- **场景覆盖**:优先覆盖 80% 的常见业务场景
|
||||||
|
- **扩展机制**:为剩余 20% 的特殊场景预留扩展接口
|
||||||
|
### 3.<span style="background-color: red;color:#fff">避免过度封装:不追求完美覆盖所有场景,保持组件的可维护性</span>。
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
### x-n-data-table
|
||||||
|
数据表格组件,增强了以下能力:
|
||||||
|
- ✨ 拖拽排序(支持整行/手柄模式)
|
||||||
|
- ✨ 列级别的插槽系统
|
||||||
|
- 🎯 统一的样式和交互
|
||||||
|
|
||||||
|
**权衡点**:
|
||||||
|
- 牺牲了一定的性能来换取更好的开发体验
|
||||||
|
- 固化了部分样式以确保视觉一致性
|
||||||
|
|
||||||
|
### x-n-modal
|
||||||
|
模态框组件,预设了常用配置:
|
||||||
|
- ✨ 统一的挂载点管理
|
||||||
|
- ✨ 预设的关闭行为
|
||||||
|
- 🎯 居中布局和统一样式
|
||||||
|
|
||||||
|
**权衡点**:
|
||||||
|
- 限制了一些灵活性以确保使用的一致性
|
||||||
|
- 强制了某些最佳实践(如挂载点)
|
||||||
|
|
||||||
|
### x-n-upload
|
||||||
|
文件上传组件,增强了以下功能:
|
||||||
|
- ✨ 统一的文件处理逻辑
|
||||||
|
- ✨ 内置预览能力
|
||||||
|
- 🎯 更友好的类型支持
|
||||||
|
|
||||||
|
**权衡点**:
|
||||||
|
- 上传接口格式固定,需要后端配合
|
||||||
|
- 为了通用性,部分特殊格式需要额外处理
|
||||||
|
|
||||||
|
### x-search-form
|
||||||
|
搜索表单组件,提供了:
|
||||||
|
- ✨ 声明式配置
|
||||||
|
- ✨ 自动布局
|
||||||
|
- 🎯 统一的搜索重置行为
|
||||||
|
|
||||||
|
**权衡点**:
|
||||||
|
- 牺牲了一些布局灵活性换取使用便利性
|
||||||
|
- 配置项相对复杂,但换来了更好的复用性
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 组件使用建议
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- 推荐:使用声明式配置 -->
|
||||||
|
<x-search-form
|
||||||
|
:search-config="searchConfig"
|
||||||
|
:cols="4"
|
||||||
|
@change="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 不推荐:内联复杂配置 -->
|
||||||
|
<x-search-form
|
||||||
|
:search-config="[
|
||||||
|
{ type: 'input', key: 'name', label: '姓名' },
|
||||||
|
{ type: 'select', key: 'status', label: '状态' }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置管理建议
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 推荐:将配置抽离到单独的配置文件
|
||||||
|
import { searchConfig } from './config'
|
||||||
|
import { tableConfig } from './config'
|
||||||
|
|
||||||
|
// 不推荐:在组件内部直接定义<E5AE9A><E4B989><EFBFBD>杂配置
|
||||||
|
const searchConfig = [
|
||||||
|
// ... 大量配置
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **性能考虑**
|
||||||
|
- 大数据量场景下,优先使用原生组件
|
||||||
|
- 合理使用 `shallowRef` 和 `markRaw`
|
||||||
|
- 避免不必要的响应式转换
|
||||||
|
|
||||||
|
2. **扩展性保证**
|
||||||
|
- 使用 `v-bind` 透传原组件属性
|
||||||
|
- 预留合理的插槽接口
|
||||||
|
- 导出必要的类型定义
|
||||||
|
|
||||||
|
3. **代码质量**
|
||||||
|
- 统一的错误处理机制
|
||||||
|
- 完善的类型声明
|
||||||
|
- 详细的文档注释
|
||||||
|
|
||||||
|
## 未来规划
|
||||||
|
|
||||||
|
1. **组件增强**
|
||||||
|
- 添加更多常用预设
|
||||||
|
- 优化性能表现
|
||||||
|
- 增加更多定制选项
|
||||||
|
|
||||||
|
2. **文档完善**
|
||||||
|
- 补充更多使用示例
|
||||||
|
- 添加在线演示
|
||||||
|
- 完善类型声明
|
||||||
|
|
||||||
|
3. **工具支持**
|
||||||
|
- 提供配置生成器
|
||||||
|
- 添加主题定制能力
|
||||||
|
- 集成表单验证工具
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
1. **组件开发原则**
|
||||||
|
- 保持简单性
|
||||||
|
- 关注通用性
|
||||||
|
- 预留扩展性
|
||||||
|
|
||||||
|
2. **代码规范**
|
||||||
|
- 遵循项目 ESLint 配置
|
||||||
|
- 编写单元测试
|
||||||
|
- 提供完整文档
|
||||||
|
|
54
src/components/x-naive-ui/x-address-select/index.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import levelTwo from "./data/pc-code.json";
|
||||||
|
import levelThree from "./data/pca-code.json";
|
||||||
|
import levelFour from "./data/pcas-code.json";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 3
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const cascaderRef = ref(null);
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:value']);
|
||||||
|
const levelMap = {
|
||||||
|
2: levelTwo,
|
||||||
|
3: levelThree,
|
||||||
|
4: levelFour
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = computed(() => levelMap[props.level] || []);
|
||||||
|
|
||||||
|
const updateValue = (value, option) => {
|
||||||
|
emit("update:value", value);
|
||||||
|
};
|
||||||
|
defineExpose({
|
||||||
|
cascaderRef
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-cascader
|
||||||
|
ref="cascaderRef"
|
||||||
|
:value="value"
|
||||||
|
placeholder="请选择"
|
||||||
|
:options="options"
|
||||||
|
showPath
|
||||||
|
check-strategy="child"
|
||||||
|
value-field="code"
|
||||||
|
label-field="name"
|
||||||
|
filterable
|
||||||
|
@update:value="updateValue"
|
||||||
|
v-bind="{...$attrs}"
|
||||||
|
/>
|
||||||
|
</template>
|
251
src/components/x-naive-ui/x-n-data-table/README.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# @x-n-data-table
|
||||||
|
|
||||||
|
基于 Naive UI 的 n-data-table 组件封装,增加了拖拽排序功能和灵活的插槽支持。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 支持所有 n-data-table 的原有功能
|
||||||
|
- 支持列拖拽排序
|
||||||
|
- 支持拖拽手柄模式
|
||||||
|
- ✨ 支持每列的自定义插槽
|
||||||
|
- ✨ 支持列标题的自定义插槽
|
||||||
|
- 支持自定义拖拽列渲染
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 项目中已经包含此组件,无需额外安装
|
||||||
|
```
|
||||||
|
|
||||||
|
## 插槽功能
|
||||||
|
|
||||||
|
> 💡 这是对原生 n-data-table 的重要增强:支持为每一列配置具名插槽
|
||||||
|
|
||||||
|
### 列内容插槽
|
||||||
|
|
||||||
|
使用列的 `key` 作为插槽名称:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<x-n-data-table :columns="columns" :data="data">
|
||||||
|
<!-- 使用 name 列的插槽 -->
|
||||||
|
<template #name="{ row, index }">
|
||||||
|
<n-tag>{{ row.name }}</n-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 使用 status 列的插槽 -->
|
||||||
|
<template #status="{ row }">
|
||||||
|
<n-badge :status="row.status" />
|
||||||
|
</template>
|
||||||
|
</x-n-data-table>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 列标题插槽
|
||||||
|
|
||||||
|
使用 `{key}_title` 作为插槽名称:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<x-n-data-table :columns="columns" :data="data">
|
||||||
|
<!-- 自定义 name 列的标题 -->
|
||||||
|
<template #name_title>
|
||||||
|
<n-space>
|
||||||
|
<n-icon><user /></n-icon>
|
||||||
|
<span>用户名</span>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</x-n-data-table>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<x-n-data-table
|
||||||
|
:columns="columns"
|
||||||
|
:data="data"
|
||||||
|
>
|
||||||
|
<!-- 自定义拖拽列的内容 -->
|
||||||
|
<template #sort="{ row, index }">
|
||||||
|
<n-space>
|
||||||
|
<n-icon>⋮⋮</n-icon>
|
||||||
|
<span>{{ index + 1 }}</span>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 自定义名称列的标题 -->
|
||||||
|
<template #name_title>
|
||||||
|
<n-space>
|
||||||
|
<n-icon><list /></n-icon>
|
||||||
|
<span>项目名称</span>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 自定义名称列的内容 -->
|
||||||
|
<template #name="{ row }">
|
||||||
|
<n-ellipsis>
|
||||||
|
{{ row.name }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</template>
|
||||||
|
</x-n-data-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const data = ref([
|
||||||
|
{ id: 1, name: '项目1' },
|
||||||
|
{ id: 2, name: '项目2' },
|
||||||
|
{ id: 3, name: '项目3' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'sort',
|
||||||
|
title: '排序',
|
||||||
|
type: 'drag',
|
||||||
|
handle: true,
|
||||||
|
onDragEnd: ({ oldIndex, newIndex }) => {
|
||||||
|
const newData = [...data.value]
|
||||||
|
const [removed] = newData.splice(oldIndex, 1)
|
||||||
|
newData.splice(newIndex, 0, removed)
|
||||||
|
data.value = newData
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
title: '名称'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| columns | `Array<Column \| DragColumn>` | `[]` | 列配置,支持拖拽列 |
|
||||||
|
| data | `Array<object>` | `[]` | 数据源 |
|
||||||
|
| align | `string` | `'center'` | 对齐方式 |
|
||||||
|
|
||||||
|
其他属性与 n-data-table 保持一致。
|
||||||
|
|
||||||
|
### Slots
|
||||||
|
|
||||||
|
| 插槽名 | 参数 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `{key}` | `{ row, index }` | 列内容的自定义渲染,key 为列的 key |
|
||||||
|
| `{key}_title` | - | 列标题的自定义渲染,key 为列的 key |
|
||||||
|
|
||||||
|
### DragColumn 配置
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| type | `'drag'` | - | 指定为拖拽列 |
|
||||||
|
| handle | `boolean` | `false` | 是否只能通过手柄拖拽 |
|
||||||
|
| onDragEnd | `(event: DragSortEvent) => void` | - | 拖拽结束回调 |
|
||||||
|
|
||||||
|
### DragSortEvent
|
||||||
|
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| oldIndex | `number` | 拖拽前的索引 |
|
||||||
|
| newIndex | `number` | 拖拽后的索引 |
|
||||||
|
|
||||||
|
### 方法
|
||||||
|
|
||||||
|
组件暴露了以下方法:
|
||||||
|
|
||||||
|
| 方法名 | 参数 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| clearFilters | - | 清除过滤条件 |
|
||||||
|
| clearSorter | - | 清除排序条件 |
|
||||||
|
| filter | `(filters: any)` | 设置过滤条件 |
|
||||||
|
| page | `(page: number)` | 跳转到指定页 |
|
||||||
|
| sort | `(columnKey: string, order: 'ascend' \| 'descend' \| false)` | 设置排序 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 拖拽列的 `type` 必须设置为 `'drag'`
|
||||||
|
2. 拖拽功能需要配置 `onDragEnd` 回调来更新数据
|
||||||
|
3. 建议将拖拽列放在表格的第一列
|
||||||
|
4. 如果需要禁用整行拖拽,请设置 `handle: true`
|
||||||
|
5. 插槽名称必须与列的 `key` 对应
|
||||||
|
6. 标题插槽需要加上 `_title` 后缀
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### 完整示例
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<x-n-data-table
|
||||||
|
:columns="columns"
|
||||||
|
:data="data"
|
||||||
|
>
|
||||||
|
<!-- 拖拽列自定义渲染 -->
|
||||||
|
<template #sort="{ index }">
|
||||||
|
<n-space>
|
||||||
|
<n-icon>⋮⋮</n-icon>
|
||||||
|
<span>{{ index + 1 }}</span>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 名称列标题自定义渲染 -->
|
||||||
|
<template #name_title>
|
||||||
|
<n-space>
|
||||||
|
<n-icon><list /></n-icon>
|
||||||
|
<span>项目名称</span>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 名称列内容自定义渲染 -->
|
||||||
|
<template #name="{ row }">
|
||||||
|
<n-ellipsis>
|
||||||
|
{{ row.name }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 状态列自定义渲染 -->
|
||||||
|
<template #status="{ row }">
|
||||||
|
<n-tag :type="row.status">
|
||||||
|
{{ row.statusText }}
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
</x-n-data-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const data = ref([
|
||||||
|
{ id: 1, name: '项目1', status: 'success', statusText: '正常' },
|
||||||
|
{ id: 2, name: '项目2', status: 'warning', statusText: '警告' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'sort',
|
||||||
|
title: '排序',
|
||||||
|
type: 'drag',
|
||||||
|
handle: true,
|
||||||
|
onDragEnd: ({ oldIndex, newIndex }) => {
|
||||||
|
const newData = [...data.value]
|
||||||
|
const [removed] = newData.splice(oldIndex, 1)
|
||||||
|
newData.splice(newIndex, 0, removed)
|
||||||
|
data.value = newData
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
title: '名称'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: '状态'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
```
|
215
src/components/x-naive-ui/x-n-data-table/index.vue
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
h,
|
||||||
|
useSlots,
|
||||||
|
computed,
|
||||||
|
shallowRef,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
nextTick,
|
||||||
|
markRaw,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
import Sortable from "sortablejs";
|
||||||
|
import { debounce } from "lodash-es";
|
||||||
|
import { NDataTable } from "naive-ui";
|
||||||
|
|
||||||
|
// Props 定义
|
||||||
|
const props = defineProps({
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
type: String,
|
||||||
|
default: "center",
|
||||||
|
validator: (value) => ["left", "center", "right"].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽功能相关逻辑
|
||||||
|
*/
|
||||||
|
const useDraggable = (props, emit) => {
|
||||||
|
const dragConfig = {
|
||||||
|
handleId: "drag-handle",
|
||||||
|
handleStyle: { cursor: "move", padding: "0 4px" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragColumn = computed(() =>
|
||||||
|
props.columns?.find((col) => col.type === "drag")
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortable = shallowRef();
|
||||||
|
const nDataTableRef = shallowRef();
|
||||||
|
|
||||||
|
const initSortable = () => {
|
||||||
|
if (!dragColumn.value) return;
|
||||||
|
|
||||||
|
const tbody = nDataTableRef.value?.$el?.querySelector?.(
|
||||||
|
".n-data-table-tbody"
|
||||||
|
);
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
sortable.value = markRaw(
|
||||||
|
new Sortable(tbody, {
|
||||||
|
animation: 150,
|
||||||
|
handle: dragColumn.value.handle ? `.${dragConfig.handleId}` : undefined,
|
||||||
|
onEnd: ({ oldIndex, newIndex }) => {
|
||||||
|
if (oldIndex === newIndex) return;
|
||||||
|
|
||||||
|
const newData = [...props.data];
|
||||||
|
const [removed] = newData.splice(oldIndex, 1);
|
||||||
|
newData.splice(newIndex, 0, removed);
|
||||||
|
|
||||||
|
emit("update:data", newData);
|
||||||
|
dragColumn.value?.onDragEnd?.({
|
||||||
|
oldIndex,
|
||||||
|
newIndex,
|
||||||
|
data: newData,
|
||||||
|
row: removed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedInitSortable = debounce(initSortable, 200);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(initSortable);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
sortable.value?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data,
|
||||||
|
() => nextTick(debouncedInitSortable)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragConfig,
|
||||||
|
dragColumn,
|
||||||
|
nDataTableRef,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列渲染相关逻辑
|
||||||
|
*/
|
||||||
|
const useColumns = (props, slots, dragConfig) => {
|
||||||
|
// 创建标题
|
||||||
|
const createTitle = (column, slotKey) => {
|
||||||
|
const titleSlotKey = `${slotKey}_title`;
|
||||||
|
|
||||||
|
if (column.titleRender) {
|
||||||
|
return column.titleRender;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slots[titleSlotKey]) {
|
||||||
|
return () => slots[titleSlotKey]({ column });
|
||||||
|
}
|
||||||
|
|
||||||
|
return column.title;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建展开渲染器
|
||||||
|
const createExpandRenderer = () => {
|
||||||
|
if (!slots["templateExpand"]) return null;
|
||||||
|
return (row, index) => h(slots["templateExpand"], { row, index });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建拖拽列渲染器
|
||||||
|
const createDragColumnRenderer = (column, slotKey) => {
|
||||||
|
return (row, index) =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: [dragConfig.handleId, "drag-handle-wrapper"],
|
||||||
|
style: dragConfig.handleStyle,
|
||||||
|
onClick: column.handle ? (e) => e.stopPropagation() : undefined,
|
||||||
|
},
|
||||||
|
slots[slotKey] ? slots[slotKey]({ row, index, column }) : "⋮⋮"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建普通列渲染器
|
||||||
|
const createDefaultColumnRenderer = (column, slotKey) => {
|
||||||
|
if (slots[slotKey]) {
|
||||||
|
return (row, index) => slots[slotKey]({ row, index, column });
|
||||||
|
}
|
||||||
|
if (column.render) {
|
||||||
|
return column.render;
|
||||||
|
}
|
||||||
|
return (row) => row[column.key];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建列配置
|
||||||
|
const createColumnRender = (column) => {
|
||||||
|
const slotKey = column.key;
|
||||||
|
|
||||||
|
const baseColumn = {
|
||||||
|
...column,
|
||||||
|
align: props.align,
|
||||||
|
title: createTitle(column, slotKey),
|
||||||
|
renderExpand: createExpandRenderer(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (column.type === "drag") {
|
||||||
|
return {
|
||||||
|
...baseColumn,
|
||||||
|
render: createDragColumnRenderer(column, slotKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseColumn,
|
||||||
|
render: createDefaultColumnRenderer(column, slotKey),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return computed(() => props.columns?.map(createColumnRender) || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
const emit = defineEmits(["update:data"]);
|
||||||
|
const slots = useSlots();
|
||||||
|
|
||||||
|
// 组合功能
|
||||||
|
const { dragConfig, dragColumn, nDataTableRef } = useDraggable(props, emit);
|
||||||
|
const computedColumns = useColumns(props, slots, dragConfig);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-data-table
|
||||||
|
ref="nDataTableRef"
|
||||||
|
:class="[dragColumn?.handle ? 'handle-only-drag' : 'full-row-drag']"
|
||||||
|
remote
|
||||||
|
v-bind="{ ...$attrs, ...$props, columns: computedColumns }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drag-handle-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-only-drag tbody tr {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-row-drag tbody tr {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
</style>
|