初始化
8
.env
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
ENV = 'development'
|
||||||
|
|
||||||
|
VITE_BASE=/
|
||||||
|
VUE_APP_PREVIEW=false
|
||||||
|
VITE_BASE_API=http://172.16.100.93:9503
|
||||||
|
VITE_EPR_BASEURL=http://114.218.158.24:9020
|
||||||
|
VITE_SOCKET_API=ws://172.16.100.93:9504
|
||||||
|
VUE_APP_WEBSITE_NAME="Lumen IM"
|
6
.env.electron
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
ENV = 'production'
|
||||||
|
|
||||||
|
VITE_BASE=./
|
||||||
|
VITE_ROUTER_MODE=hash
|
||||||
|
VITE_BASE_API=https://xxx.xxx.com
|
||||||
|
VITE_SOCKET_API=wss://xxx.xxx.com
|
7
.env.production
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# just a flag
|
||||||
|
ENV = 'production'
|
||||||
|
|
||||||
|
VITE_BASE=/
|
||||||
|
VITE_ROUTER_MODE=history
|
||||||
|
VITE_BASE_API=https://xxxx.xxx.com
|
||||||
|
VITE_SOCKET_API=wss://xxxx.xxxx.com
|
23
.eslintrc.cjs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
node: true // 只需将该项设置为 true 即可
|
||||||
|
},
|
||||||
|
root: true,
|
||||||
|
'extends': [
|
||||||
|
'plugin:vue/vue3-essential',
|
||||||
|
'eslint:recommended',
|
||||||
|
'@vue/eslint-config-typescript',
|
||||||
|
'@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest'
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
"no-unused-vars":"off"
|
||||||
|
}
|
||||||
|
}
|
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist_electron
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
makefile
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
8
.prettierrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
87
README.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Lumen 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">
|
||||||
|
|
||||||
|
### 项目介绍
|
||||||
|
|
||||||
|
Lumen IM 是一个网页版在线聊天项目,前端使用 Naive UI + Vue3,后端采用 GO 开发。
|
||||||
|
|
||||||
|
### 功能模块
|
||||||
|
|
||||||
|
- 支持私聊及群聊
|
||||||
|
- 支持多种聊天消息类型 例如:文本消息、代码块、群投票、图片及其它类型文件,并支持文件下载
|
||||||
|
- 支持聊天消息撤回、删除(批量删除)、转发消息(逐条转发、合并转发)
|
||||||
|
- 支持编写笔记
|
||||||
|
|
||||||
|
### 项目预览
|
||||||
|
|
||||||
|
- 地址: [http://im.gzydong.com](http://im.gzydong.com)
|
||||||
|
|
||||||
|
### 项目安装
|
||||||
|
|
||||||
|
###### 下载安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
## 克隆项目源码包
|
||||||
|
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
|
||||||
|
VITE_BASE_API=http://127.0.0.1:9503
|
||||||
|
VITE_SOCKET_API=ws://127.0.0.1:9504
|
||||||
|
```
|
||||||
|
|
||||||
|
###### 关于 Nginx 的一些配置
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name www.yourdomain.com;
|
||||||
|
|
||||||
|
root /project-path/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf|flv|ico)$ {
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ .*\.(js|css)?$ {
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 项目源码
|
||||||
|
|
||||||
|
| 代码仓库 | 前端源码 | 后端源码 |
|
||||||
|
| -------- | ---------------------------------- | ---------------------------------- |
|
||||||
|
| 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
|
||||||
|
|
||||||
|
### 如果你觉得还不错,请 Star , Fork 给作者鼓励一下。
|
BIN
build/icons/lumen-im-mac.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
build/icons/lumen-im-win.ico
Normal file
After Width: | Height: | Size: 134 KiB |
BIN
build/icons/lumenim.icns
Normal file
BIN
build/icons/lumenim.ico
Normal file
After Width: | Height: | Size: 134 KiB |
BIN
build/icons/lumenim.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
92
electron/main.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// 控制应用生命周期和创建原生浏览器窗口的模组
|
||||||
|
const { app, BrowserWindow, ipcMain, Menu, MenuItem } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const { shell } = require('electron')
|
||||||
|
|
||||||
|
const NODE_ENV = process.env.NODE_ENV
|
||||||
|
|
||||||
|
function loadHtmlUrl() {
|
||||||
|
return NODE_ENV === 'development'
|
||||||
|
? `http://localhost:${process.env.PROT}`
|
||||||
|
: `file://${path.join(__dirname, '../dist/index.html')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
// 创建浏览器窗口
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 900,
|
||||||
|
minHeight: 600,
|
||||||
|
frame: false,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载 index.html
|
||||||
|
win.loadURL(loadHtmlUrl())
|
||||||
|
|
||||||
|
// 打开开发工具
|
||||||
|
if (NODE_ENV === 'development') {
|
||||||
|
win.webContents.openDevTools()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入全屏模式
|
||||||
|
win.on('enter-full-screen', function () {
|
||||||
|
win.webContents.send('full-screen', 'enter')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 退出全屏模式
|
||||||
|
win.on('leave-full-screen', function () {
|
||||||
|
win.webContents.send('full-screen', 'leave')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('get-full-screen', (e, data) => {
|
||||||
|
e.returnValue = win.isFullScreen()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('app-info', (e, data) => {
|
||||||
|
e.returnValue = {
|
||||||
|
platform: process.platform,
|
||||||
|
version: app.getVersion(),
|
||||||
|
appPath: app.getAppPath(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这段程序将会在 Electron 结束初始化
|
||||||
|
// 和创建浏览器窗口的时候调用
|
||||||
|
// 部分 API 在 ready 事件触发后才能使用。
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
app.on('activate', function () {
|
||||||
|
// 通常在 macOS 上,当点击 dock 中的应用程序图标时,如果没有其他
|
||||||
|
// 打开的窗口,那么程序会重新创建一个窗口。
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 除了 macOS 外,当所有窗口都被关闭的时候退出程序。 因此,通常对程序和它们在
|
||||||
|
// 任务栏上的图标来说,应当保持活跃状态,直到用户使用 Cmd + Q 退出。
|
||||||
|
app.on('window-all-closed', function () {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在这个文件中,你可以包含应用程序剩余的所有部分的代码,
|
||||||
|
// 也可以拆分成几个文件,然后用 require 导入。
|
||||||
|
|
||||||
|
ipcMain.on('ipc:set-badge', async (event, num) => {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
app.dock.setBadge(num > 99 ? '99+' : num)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('ipc:open-link', async (event, link) => {
|
||||||
|
// Open a link in the default browser
|
||||||
|
shell.openExternal(link)
|
||||||
|
})
|
46
electron/preload.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
// 暴露方法给渲染进程调用
|
||||||
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
|
// 设置消息未读数
|
||||||
|
setBadge: num => {
|
||||||
|
ipcRenderer.send('ipc:set-badge', num == 0 ? '' : `${num}`)
|
||||||
|
},
|
||||||
|
// 获取窗口全屏状态
|
||||||
|
getFullScreenStatus: () => {
|
||||||
|
return ipcRenderer.sendSync('get-full-screen', '')
|
||||||
|
},
|
||||||
|
// 系统信息
|
||||||
|
getAppPlatform: () => {
|
||||||
|
return ipcRenderer.sendSync('app-info', '')
|
||||||
|
},
|
||||||
|
|
||||||
|
openLink: link => {
|
||||||
|
ipcRenderer.send('ipc:open-link', link)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 窗口变化事件
|
||||||
|
ipcRenderer.on('full-screen', function (event, value) {
|
||||||
|
// isFullScreenStatus = value == 'enter'
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent('full-screen-event', { detail: value })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 触发自定义事件
|
||||||
|
// document.dispatchEvent(new CustomEvent('myTestEvent', {num: i}))
|
||||||
|
// document.addEventListener('myTestEvent', e => {console.log(e)})
|
||||||
|
// 所有Node.js API都可以在预加载过程中使用。
|
||||||
|
// 它拥有与Chrome扩展一样的沙盒。
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const replaceText = (selector, text) => {
|
||||||
|
const element = document.getElementById(selector)
|
||||||
|
if (element) element.innerText = text
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dependency of ['chrome', 'node', 'electron']) {
|
||||||
|
replaceText(`${dependency}-version`, process.versions[dependency])
|
||||||
|
}
|
||||||
|
})
|
9
env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import { ComponentOptions } from 'vue'
|
||||||
|
const componentOptions: ComponentOptions
|
||||||
|
export default componentOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'quill-image-uploader'
|
67
index.html
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="./src/assets/image/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Lumen IM 在线聊天</title>
|
||||||
|
<style>
|
||||||
|
.outer,
|
||||||
|
.middle,
|
||||||
|
.inner {
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-top-color: #dedada;
|
||||||
|
border-right-color: #dedada;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outer {
|
||||||
|
width: 3.5em;
|
||||||
|
height: 3.5em;
|
||||||
|
margin-left: -1.75em;
|
||||||
|
margin-top: -1.75em;
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.middle {
|
||||||
|
width: 2.1em;
|
||||||
|
height: 2.1em;
|
||||||
|
margin-left: -1.05em;
|
||||||
|
margin-top: -1.05em;
|
||||||
|
animation: spin 1.75s linear reverse infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
width: 0.8em;
|
||||||
|
height: 0.8em;
|
||||||
|
margin-left: -0.4em;
|
||||||
|
margin-top: -0.4em;
|
||||||
|
animation: spin 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="app-loading">
|
||||||
|
<div class="loader">
|
||||||
|
<div class="outer"></div>
|
||||||
|
<div class="middle"></div>
|
||||||
|
<div class="inner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
103
package.json
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"name": "LumenIM",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --mode development --port 5273",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"electron": "wait-on tcp:5174 && cross-env NODE_ENV=development PROT=5174 electron .",
|
||||||
|
"electron:dev": "concurrently -k \"npm run dev\" \"npm run electron\"",
|
||||||
|
"electron:build": "vite build --mode electron && electron-builder --mac && electron-builder --win --x64",
|
||||||
|
"electron:build-mac": "vite build --mode electron && electron-builder --mac",
|
||||||
|
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
|
"format": "prettier --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@highlightjs/vue-plugin": "^2.1.0",
|
||||||
|
"@kangc/v-md-editor": "^2.3.18",
|
||||||
|
"@vueup/vue-quill": "^1.2.0",
|
||||||
|
"@vueuse/core": "^10.7.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"highlight.js": "^11.5.0",
|
||||||
|
"js-audio-recorder": "^1.0.7",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"pinia-plugin-persistedstate": "^3.2.0",
|
||||||
|
"quill": "^1.3.7",
|
||||||
|
"quill-image-uploader": "^1.3.0",
|
||||||
|
"quill-mention": "^4.1.0",
|
||||||
|
"vue": "^3.3.11",
|
||||||
|
"vue-cropper": "^1.1.1",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"vue-virtual-scroller": "^2.0.0-beta.8",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
|
"xgplayer": "^3.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@icon-park/vue-next": "^1.4.2",
|
||||||
|
"@rushstack/eslint-patch": "^1.3.3",
|
||||||
|
"@tsconfig/node18": "^18.2.2",
|
||||||
|
"@types/node": "^18.18.5",
|
||||||
|
"@types/vue": "^2.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^4.4.0",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^3.0.2",
|
||||||
|
"@vue/eslint-config-prettier": "^8.0.0",
|
||||||
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
|
"@vue/tsconfig": "^0.4.0",
|
||||||
|
"concurrently": "^7.3.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^19.1.9",
|
||||||
|
"electron-builder": "^23.6.0",
|
||||||
|
"eslint": "^8.49.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
|
"eslint-plugin-vue": "^9.17.0",
|
||||||
|
"less": "^4.2.0",
|
||||||
|
"less-loader": "^11.1.3",
|
||||||
|
"naive-ui": "^2.35.0",
|
||||||
|
"npm-run-all2": "^6.1.1",
|
||||||
|
"prettier": "^3.1.0",
|
||||||
|
"typescript": "~5.2.0",
|
||||||
|
"vite": "^4.5.1",
|
||||||
|
"vite-plugin-compression": "^0.5.1",
|
||||||
|
"vue-tsc": "^1.8.25",
|
||||||
|
"wait-on": "^6.0.1"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.gzydong.lumenim",
|
||||||
|
"productName": "LumenIM",
|
||||||
|
"copyright": "Copyright © 2023 LumenIM",
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.utilities",
|
||||||
|
"icon": "build/icons/lumen-im-mac.png"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"icon": "build/icons/lumen-im-mac.png",
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"installerIcon": "build/icons/lumen-im-win.ico",
|
||||||
|
"uninstallerIcon": "build/icons/lumen-im-win.ico",
|
||||||
|
"installerHeaderIcon": "build/icons/lumen-im-win.ico",
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "lumeim-icon"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "assets",
|
||||||
|
"output": "dist_electron"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 134 KiB |
1
public/favicon.svg
Normal file
After Width: | Height: | Size: 6.4 KiB |
101
src/App.vue
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import '@icon-park/vue-next/styles/index.css'
|
||||||
|
import { IconProvider, DEFAULT_ICON_CONFIGS } from '@icon-park/vue-next'
|
||||||
|
import {
|
||||||
|
NNotificationProvider,
|
||||||
|
NMessageProvider,
|
||||||
|
NDialogProvider,
|
||||||
|
NConfigProvider,
|
||||||
|
zhCN,
|
||||||
|
dateZhCN,
|
||||||
|
NLayoutContent
|
||||||
|
} from 'naive-ui'
|
||||||
|
import hljs from 'highlight.js/lib/core'
|
||||||
|
import { useUserStore, useTalkStore } from '@/store'
|
||||||
|
import ws from '@/connect'
|
||||||
|
import { bus } from '@/utils/event-bus'
|
||||||
|
import { isLoggedIn } from '@/utils/auth'
|
||||||
|
import { NotificationApi, MessageApi, DialogApi } from '@/components/common'
|
||||||
|
import UserCardModal from '@/components/user/UserCardModal.vue'
|
||||||
|
import { ContactConst } from '@/constant/event-bus'
|
||||||
|
import {
|
||||||
|
useProvideUserModal,
|
||||||
|
useThemeMode,
|
||||||
|
useVisibilityChange,
|
||||||
|
useAccessPrompt,
|
||||||
|
useUnreadMessage,
|
||||||
|
useConnectStatus,
|
||||||
|
useClickEvent
|
||||||
|
} from '@/hooks'
|
||||||
|
|
||||||
|
IconProvider({
|
||||||
|
...DEFAULT_ICON_CONFIGS,
|
||||||
|
theme: 'outline',
|
||||||
|
size: 24,
|
||||||
|
strokeWidth: 3,
|
||||||
|
strokeLinejoin: 'bevel'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { uid: showUserId, isShow: isShowUser } = useProvideUserModal()
|
||||||
|
const { getDarkTheme, getThemeOverride } = useThemeMode()
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const talkStore = useTalkStore()
|
||||||
|
|
||||||
|
const onChangeRemark = (value: string) => {
|
||||||
|
bus.emit(ContactConst.UpdateRemark, value)
|
||||||
|
talkStore.setRemark(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
if (!isLoggedIn()) return
|
||||||
|
|
||||||
|
ws.connect()
|
||||||
|
userStore.loadSetting()
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
useVisibilityChange()
|
||||||
|
useAccessPrompt()
|
||||||
|
useUnreadMessage()
|
||||||
|
useConnectStatus()
|
||||||
|
useClickEvent()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--接收信息提示音-->
|
||||||
|
<audio id="audio" preload="preload" muted>
|
||||||
|
<source src="@/assets/music.mp3" type="audio/mp3" />
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<!-- 调整 naive-ui 的字重配置 -->
|
||||||
|
<n-config-provider
|
||||||
|
:theme="getDarkTheme"
|
||||||
|
:theme-overrides="getThemeOverride"
|
||||||
|
:locale="zhCN"
|
||||||
|
:date-locale="dateZhCN"
|
||||||
|
:hljs="hljs"
|
||||||
|
>
|
||||||
|
<n-message-provider>
|
||||||
|
<message-api />
|
||||||
|
</n-message-provider>
|
||||||
|
|
||||||
|
<n-notification-provider>
|
||||||
|
<notification-api />
|
||||||
|
</n-notification-provider>
|
||||||
|
|
||||||
|
<n-dialog-provider>
|
||||||
|
<dialog-api />
|
||||||
|
</n-dialog-provider>
|
||||||
|
|
||||||
|
<n-layout-content>
|
||||||
|
<router-view />
|
||||||
|
|
||||||
|
<UserCardModal
|
||||||
|
v-model:show="isShowUser"
|
||||||
|
v-model:uid="showUserId"
|
||||||
|
@update-remark="onChangeRemark"
|
||||||
|
/>
|
||||||
|
</n-layout-content>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
138
src/api/article.js
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { post, get, upload } from '@/utils/request'
|
||||||
|
import { getAccessToken } from '@/utils/auth'
|
||||||
|
|
||||||
|
// -------- 笔记相关 --------
|
||||||
|
|
||||||
|
// 查询用户文集分类服务接口
|
||||||
|
export const ServeGetArticleList = (data) => {
|
||||||
|
return get('/api/v1/note/article/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑笔记服务接口
|
||||||
|
export const ServeEditArticle = (data) => {
|
||||||
|
return post('/api/v1/note/article/editor', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除笔记服务接口
|
||||||
|
export const ServeDeleteArticle = (data) => {
|
||||||
|
return post('/api/v1/note/article/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 永久删除笔记回收站的笔记
|
||||||
|
export const ServeForeverDeleteArticle = (data) => {
|
||||||
|
return post('/api/v1/note/article/forever/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复笔记服务接口
|
||||||
|
export const ServeRecoverArticle = (data) => {
|
||||||
|
return post('/api/v1/note/article/recover', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置标记星号笔记服务接口
|
||||||
|
export const ServeSetAsteriskArticle = (data) => {
|
||||||
|
return post('/api/v1/note/article/asterisk', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询用户文集分类服务接口
|
||||||
|
export const ServeGetArticleDetail = (data) => {
|
||||||
|
return get('/api/v1/note/article/detail', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动笔记服务接口
|
||||||
|
export const ServeMoveArticle = (data) => {
|
||||||
|
return post('/api/v1/note/article/move', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 笔记图片上传服务接口
|
||||||
|
export const ServeUploadArticleImg = (data) => {
|
||||||
|
return upload('/api/v1/note/article/upload/image', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新笔记标签服务接口
|
||||||
|
export const ServeUpdateArticleTag = (data) => {
|
||||||
|
return post('/api/v1/note/article/tag', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- 笔记分类相关 --------
|
||||||
|
|
||||||
|
// 查询用户文集分类服务接口
|
||||||
|
export const ServeGetArticleClass = (data) => {
|
||||||
|
return get('/api/v1/note/class/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加或编辑文集分类服务接口
|
||||||
|
export const ServeEditArticleClass = (data) => {
|
||||||
|
return post('/api/v1/note/class/editor', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除笔记分类服务接口
|
||||||
|
export const ServeDeleteArticleClass = (data) => {
|
||||||
|
return post('/api/v1/note/class/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 笔记分类排序服务接口
|
||||||
|
export const ServeArticleClassSort = (data) => {
|
||||||
|
return post('/api/v1/note/class/sort', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并笔记分类服务接口
|
||||||
|
export const ServeMergeArticleClass = (data) => {
|
||||||
|
return post('/api/v1/note/article/merge', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- 笔记标签相关 --------
|
||||||
|
|
||||||
|
// 获取笔记表标签服务接口
|
||||||
|
export const ServeGetArticleTag = (data) => {
|
||||||
|
return get('/api/v1/note/tag/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加或编辑笔记标签服务接口
|
||||||
|
export const ServeEditArticleTag = (data) => {
|
||||||
|
return post('/api/v1/note/tag/editor', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除笔记标签服务接口
|
||||||
|
export const ServeDeleteArticleTag = (data) => {
|
||||||
|
return post('/api/v1/note/tag/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- 笔记附件相关 --------
|
||||||
|
|
||||||
|
// 笔记附件上传服务接口
|
||||||
|
export const ServeUploadArticleAnnex = (data) => {
|
||||||
|
return upload('/api/v1/note/annex/upload', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除笔记附件服务接口
|
||||||
|
export const ServeDeleteArticleAnnex = (data) => {
|
||||||
|
return post('/api/v1/note/annex/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 永久删除笔记附件回收站文件
|
||||||
|
export const ServeForeverDeleteAnnex = (data) => {
|
||||||
|
return post('/api/v1/note/annex/forever/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复笔记附件服务接口
|
||||||
|
export const ServeRecoverArticleAnnex = (data) => {
|
||||||
|
return post('/api/v1/note/annex/recover', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 笔记附件回收站列表服务接口
|
||||||
|
export const ServeGetRecoverAnnexList = () => {
|
||||||
|
return get('/api/v1/note/annex/recover/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载笔记附件服务接口
|
||||||
|
export const ServeDownloadAnnex = (annex_id) => {
|
||||||
|
let api = import.meta.env.VITE_BASE_API
|
||||||
|
try {
|
||||||
|
let link = document.createElement('a')
|
||||||
|
// link.target = '_blank'
|
||||||
|
link.href = `${api}/api/v1/note/annex/download?annex_id=${annex_id}&token=${getAccessToken()}`
|
||||||
|
link.click()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
27
src/api/auth.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// 授权相关接口
|
||||||
|
import { post } from '@/utils/request'
|
||||||
|
|
||||||
|
// 登录服务接口
|
||||||
|
export const ServeLogin = (data) => {
|
||||||
|
return post('/api/v1/auth/login', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册服务接口
|
||||||
|
export const ServeRegister = (data) => {
|
||||||
|
return post('/api/v1/auth/register', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录服务接口
|
||||||
|
export const ServeLogout = (data) => {
|
||||||
|
return post('/api/v1/auth/logout', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新登录Token服务接口
|
||||||
|
export const ServeRefreshToken = () => {
|
||||||
|
return post('/api/v1/auth/refresh-token')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找回密码服务
|
||||||
|
export const ServeForgetPassword = (data) => {
|
||||||
|
return post('/api/v1/auth/forget', data)
|
||||||
|
}
|
88
src/api/chat.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { post, get, upload } from '@/utils/request'
|
||||||
|
|
||||||
|
// 获取聊天列表服务接口
|
||||||
|
export const ServeGetTalkList = (data = {}) => {
|
||||||
|
return get('/api/v1/talk/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聊天列表创建服务接口
|
||||||
|
export const ServeCreateTalkList = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/create', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除聊天列表服务接口
|
||||||
|
export const ServeDeleteTalkList = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话列表置顶服务接口
|
||||||
|
export const ServeTopTalkList = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/topping', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除聊天消息未读数服务接口
|
||||||
|
export const ServeClearTalkUnreadNum = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/unread/clear', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取聊天记录服务接口
|
||||||
|
export const ServeTalkRecords = (data = {}) => {
|
||||||
|
return get('/api/v1/talk/records', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取转发会话记录详情列表服务接口
|
||||||
|
export const ServeGetForwardRecords = (data = {}) => {
|
||||||
|
return get('/api/v1/talk/records/forward', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话列表置顶服务接口
|
||||||
|
export const ServeSetNotDisturb = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/disturb', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找用户聊天记录服务接口
|
||||||
|
export const ServeFindTalkRecords = (data = {}) => {
|
||||||
|
return get('/api/v1/talk/records/history', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索用户聊天记录服务接口
|
||||||
|
export const ServeSearchTalkRecords = (data = {}) => {
|
||||||
|
return get('/api/v1/talk/search-chat-records', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeGetRecordsContext = (data = {}) => {
|
||||||
|
return get('/api/v1/talk/get-records-context', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送代码块消息服务接口
|
||||||
|
export const ServePublishMessage = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/message/publish', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送聊天文件服务接口
|
||||||
|
export const ServeSendTalkFile = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/message/file', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撤回消息服务接口
|
||||||
|
export const ServeRevokeRecords = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/message/revoke', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除消息服务接口
|
||||||
|
export const ServeRemoveRecords = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/message/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏表情包服务接口
|
||||||
|
export const ServeCollectEmoticon = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/message/collect', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeSendVote = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/message/vote', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeConfirmVoteHandle = (data = {}) => {
|
||||||
|
return post('/api/v1/talk/message/vote/handle', data)
|
||||||
|
}
|
11
src/api/common.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { post } from '@/utils/request'
|
||||||
|
|
||||||
|
// 发送找回密码验证码
|
||||||
|
export const ServeSendVerifyCode = (data) => {
|
||||||
|
return post('/api/v1/common/sms-code', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送邮箱验证码服务接口
|
||||||
|
export const ServeSendEmailCode = (data) => {
|
||||||
|
return post('/api/v1/common/email-code', data)
|
||||||
|
}
|
63
src/api/contact.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { post, get } from '@/utils/request'
|
||||||
|
|
||||||
|
// 获取好友列表服务接口
|
||||||
|
export const ServeGetContacts = (data) => {
|
||||||
|
return get('/api/v1/users/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解除好友关系服务接口
|
||||||
|
export const ServeDeleteContact = (data) => {
|
||||||
|
return post('/api/v1/contact/delete', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改好友备注服务接口
|
||||||
|
export const ServeEditContactRemark = (data) => {
|
||||||
|
return post('/api/v1/contact/edit-remark', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索联系人
|
||||||
|
export const ServeSearchContact = (data) => {
|
||||||
|
return get('/api/v1/contact/search', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 好友申请服务接口
|
||||||
|
export const ServeCreateContact = (data) => {
|
||||||
|
return post('/api/v1/contact/apply/create', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询好友申请服务接口
|
||||||
|
export const ServeGetContactApplyRecords = (data) => {
|
||||||
|
return get('/api/v1/contact/apply/records', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理好友申请服务接口
|
||||||
|
export const ServeApplyAccept = (data) => {
|
||||||
|
return post('/api/v1/contact/apply/accept', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeApplyDecline = (data) => {
|
||||||
|
return post('/api/v1/contact/apply/decline', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询好友申请未读数量服务接口
|
||||||
|
export const ServeFindFriendApplyNum = () => {
|
||||||
|
return get('/api/v1/contact/apply/unread-num')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索用户信息服务接口
|
||||||
|
export const ServeSearchUser = (data) => {
|
||||||
|
return get('/api/v1/contact/detail', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索用户信息服务接口
|
||||||
|
export const ServeContactGroupList = (data) => {
|
||||||
|
return get('/api/v1/contact/group/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeContactMoveGroup = (data) => {
|
||||||
|
return post('/api/v1/contact/move-group', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeContactGroupSave = (data) => {
|
||||||
|
return post('/api/v1/contact/group/save', data)
|
||||||
|
}
|
30
src/api/emoticon.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { post, get, upload } from '@/utils/request'
|
||||||
|
|
||||||
|
// 查询用户表情包服务接口
|
||||||
|
export const ServeFindUserEmoticon = () => {
|
||||||
|
return get('/api/v1/emoticon/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询系统表情包服务接口
|
||||||
|
export const ServeFindSysEmoticon = () => {
|
||||||
|
return get('/api/v1/emoticon/system/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置用户表情包服务接口
|
||||||
|
export const ServeSetUserEmoticon = (data) => {
|
||||||
|
return post('/api/v1/emoticon/system/install', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除收藏表情包服务接口
|
||||||
|
export const ServeDelCollectEmoticon = (data) => {
|
||||||
|
return post('/api/v1/emoticon/del-collect-emoticon', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传表情包服务接口
|
||||||
|
export const ServeUploadEmoticon = (data) => {
|
||||||
|
return upload('/api/v1/emoticon/customize/create', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeDeleteEmoticon = (data) => {
|
||||||
|
return upload('/api/v1/emoticon/customize/delete', data)
|
||||||
|
}
|
116
src/api/group.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { post, get } from '@/utils/request'
|
||||||
|
|
||||||
|
// 查询用户群聊服务接口
|
||||||
|
export const ServeGetGroups = () => {
|
||||||
|
return get('/api/v1/group/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeGroupOvertList = (data) => {
|
||||||
|
return get('/api/v1/group/overt/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取群信息服务接口
|
||||||
|
export const ServeGroupDetail = (data) => {
|
||||||
|
return get('/api/v1/group/detail', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建群聊服务接口
|
||||||
|
export const ServeCreateGroup = (data) => {
|
||||||
|
return post('/api/v1/group/create', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改群信息
|
||||||
|
export const ServeEditGroup = (data) => {
|
||||||
|
return post('/api/v1/group/setting', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邀请好友加入群聊服务接口
|
||||||
|
export const ServeInviteGroup = (data) => {
|
||||||
|
return post('/api/v1/group/invite', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除群聊成员服务接口
|
||||||
|
export const ServeRemoveMembersGroup = (data) => {
|
||||||
|
return post('/api/v1/group/member/remove', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员解散群聊服务接口
|
||||||
|
export const ServeDismissGroup = (data) => {
|
||||||
|
return post('/api/v1/group/dismiss', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeMuteGroup = (data) => {
|
||||||
|
return post('/api/v1/group/mute', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeOvertGroup = (data) => {
|
||||||
|
return post('/api/v1/group/overt', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户退出群聊服务接口
|
||||||
|
export const ServeSecedeGroup = (data) => {
|
||||||
|
return post('/api/v1/group/secede', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改群聊名片服务接口
|
||||||
|
export const ServeUpdateGroupCard = (data) => {
|
||||||
|
return post('/api/v1/group/member/remark', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户可邀请加入群聊的好友列表
|
||||||
|
export const ServeGetInviteFriends = (data) => {
|
||||||
|
return get('/api/v1/group/member/invites', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取群聊成员列表
|
||||||
|
export const ServeGetGroupMembers = (data) => {
|
||||||
|
return get('/api/v1/group/member/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取群聊公告列表
|
||||||
|
export const ServeGetGroupNotices = (data) => {
|
||||||
|
return get('/api/v1/group/notice/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑群公告
|
||||||
|
export const ServeEditGroupNotice = (data) => {
|
||||||
|
return post('/api/v1/group/notice/edit', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeGetGroupApplyList = (data) => {
|
||||||
|
return get('/api/v1/group/apply/list', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeGetGroupApplyAll = (data) => {
|
||||||
|
return get('/api/v1/group/apply/all', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeDeleteGroupApply = (data) => {
|
||||||
|
return post('/api/v1/group/apply/decline', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeAgreeGroupApply = (data) => {
|
||||||
|
return post('/api/v1/group/apply/agree', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeCreateGroupApply = (data) => {
|
||||||
|
return post('/api/v1/group/apply/create', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeGroupApplyUnread = (data) => {
|
||||||
|
return get('/api/v1/group/apply/unread', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转让群主
|
||||||
|
export const ServeGroupHandover = (data) => {
|
||||||
|
return post('/api/v1/group/handover', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分配管理员
|
||||||
|
export const ServeGroupAssignAdmin = (data) => {
|
||||||
|
return post('/api/v1/group/assign-admin', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeGroupNoSpeak = (data) => {
|
||||||
|
return post('/api/v1/group/no-speak', data)
|
||||||
|
}
|
13
src/api/organize.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { get } from '@/utils/request'
|
||||||
|
|
||||||
|
export const ServeDepartmentList = () => {
|
||||||
|
return get('/api/v1/organize/department/all')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServePersonnelList = () => {
|
||||||
|
return get('/api/v1/organize/personnel/all')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServeCheckQiyeMember = () => {
|
||||||
|
return get('/api/v1/organize/member/check')
|
||||||
|
}
|
26
src/api/upload.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { post, upload } from '@/utils/request'
|
||||||
|
|
||||||
|
// 上传头像裁剪图片服务接口
|
||||||
|
export const ServeUploadAvatar = (data) => {
|
||||||
|
return post('/api/v1/upload/avatar', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传头像裁剪图片服务接口
|
||||||
|
export const ServeUploadImage = (data) => {
|
||||||
|
return post('/api/v1/upload/image', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询大文件拆分信息服务接口
|
||||||
|
export const ServeFindFileSplitInfo = (data = {}) => {
|
||||||
|
return post('/api/v1/upload/multipart/initiate', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件拆分上传服务接口
|
||||||
|
export const ServeFileSubareaUpload = (data = {}, options = {}) => {
|
||||||
|
return upload('/api/v1/upload/multipart', data, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传图片文件或者视频
|
||||||
|
export const uploadImg = (data) => {
|
||||||
|
return post('/upload/img', data,{baseURL:import.meta.env.VITE_EPR_BASEURL})
|
||||||
|
}
|
31
src/api/user.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { post, get } from '@/utils/request'
|
||||||
|
|
||||||
|
// 修改密码服务接口
|
||||||
|
export const ServeUpdatePassword = (data) => {
|
||||||
|
return post('/api/v1/users/change/password', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改手机号服务接口
|
||||||
|
export const ServeUpdateMobile = (data) => {
|
||||||
|
return post('/api/v1/users/change/mobile', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改手机号服务接口
|
||||||
|
export const ServeUpdateEmail = (data) => {
|
||||||
|
return post('/api/v1/users/change/email', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改个人信息服务接口
|
||||||
|
export const ServeUpdateUserDetail = (data) => {
|
||||||
|
return post('/api/v1/users/change/detail', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询用户信息服务接口
|
||||||
|
export const ServeGetUserDetail = () => {
|
||||||
|
return get('/api/v1/users/detail')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户相关设置信息
|
||||||
|
export const ServeGetUserSetting = () => {
|
||||||
|
return get('/api/v1/users/setting')
|
||||||
|
}
|
69
src/assets/css/contact.less
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
.title {
|
||||||
|
height: 60px;
|
||||||
|
line-height: 60px;
|
||||||
|
padding-left: 15px;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-box {
|
||||||
|
padding: 15px;
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: 15px;
|
||||||
|
|
||||||
|
.view-list {
|
||||||
|
height: 60px;
|
||||||
|
margin: 5px 0;
|
||||||
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
padding-left: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: padding 0.5s ease-in-out;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.selectd {
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid rgb(80 138 254);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 60px;
|
||||||
|
margin-right: 5px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: auto;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-size: 15px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
width: 100px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
289
src/assets/css/define/global.less
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Alibaba PuHuiTi 2.0 45';
|
||||||
|
src:
|
||||||
|
url('../../fonts/AlibabaPuHuiTi/AlibabaPuHuiTi_2_45_Light.woff2') format('woff2'),
|
||||||
|
url('../../fonts/AlibabaPuHuiTi/AlibabaPuHuiTi_2_45_Light.woff') format('woff');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 500px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family:
|
||||||
|
LarkHackSafariFont,
|
||||||
|
LarkEmojiFont,
|
||||||
|
LarkChineseQuote,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
Helvetica Neue,
|
||||||
|
Segoe UI,
|
||||||
|
PingFang SC,
|
||||||
|
Microsoft Yahei,
|
||||||
|
Arial,
|
||||||
|
Hiragino Sans GB,
|
||||||
|
sans-serif,
|
||||||
|
Apple Color Emoji,
|
||||||
|
Segoe UI Emoji,
|
||||||
|
Segoe UI Symbol,
|
||||||
|
Noto Color Emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
img {
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: none;
|
||||||
|
outline: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
font-family: 'Microsoft YaHei';
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.height100 {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex: 1;
|
||||||
|
flex-basis: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-container.is-vertical,
|
||||||
|
.el-drawer {
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-aside,
|
||||||
|
.el-header {
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-container.is-vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-aside,
|
||||||
|
.el-header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-aside {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-footer,
|
||||||
|
.el-main {
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-main {
|
||||||
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
flex-basis: auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-footer,
|
||||||
|
.el-main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动条样式
|
||||||
|
.me-scrollbar {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
background-color: var(--im-scrollbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--im-scrollbar-thumb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.me-scrollbar-thumb {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局边框
|
||||||
|
.bdr-t {
|
||||||
|
border-top: 1px solid var(--line-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bdr-r {
|
||||||
|
border-right: 1px solid var(--line-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bdr-b {
|
||||||
|
border-bottom: 1px solid var(--line-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bdr-l {
|
||||||
|
border-left: 1px solid var(--line-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
background-color: #dee0e3;
|
||||||
|
transform: scale(0.84);
|
||||||
|
transform-origin: left;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-l5 {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-l15 {
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-t20 {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-b10 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pd-10 {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.pd-t15 {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.pd-t20 {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-ellipsis {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
color: #2196f3;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-radius {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-drawer-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xg-options-list {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.me-view-header {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-rotate {
|
||||||
|
animation: rotate 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
75
src/assets/css/define/theme.less
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// 默认主题
|
||||||
|
html {
|
||||||
|
--im-primary-color: #1890ff;
|
||||||
|
--im-bg-color: #ffffff;
|
||||||
|
--line-border-color: #f5f5f5;
|
||||||
|
--border-color: #eeeaea;
|
||||||
|
--im-text-color: #333;
|
||||||
|
--im-text-color-grey: #333;
|
||||||
|
--im-active-bg-color: #f5f5f5;
|
||||||
|
--im-hover-bg-color: #f5f5f5;
|
||||||
|
|
||||||
|
--im-broadside-box-shadow: rgba(29, 35, 41, 0.05);
|
||||||
|
|
||||||
|
// note
|
||||||
|
--im-note-list-bg-color: #f4f6f9;
|
||||||
|
|
||||||
|
// 滚动条
|
||||||
|
--im-scrollbar: #e4e4e5;
|
||||||
|
--im-scrollbar-thumb: #c0bebc;
|
||||||
|
|
||||||
|
// message
|
||||||
|
--im-message-bg-color: #f7f7f7;
|
||||||
|
--im-message-border-color: #efeff5;
|
||||||
|
--im-message-left-bg-color: #eff0f1;
|
||||||
|
--im-message-left-text-color: #333;
|
||||||
|
--im-message-right-bg-color: #daf3fd;
|
||||||
|
--im-message-right-text-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 黑色主题
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
--im-primary-color: #1890ff;
|
||||||
|
--im-bg-color: #202124;
|
||||||
|
--line-border-color: rgb(255 255 255 / 9%);
|
||||||
|
--border-color: rgb(255 255 255 / 9%);
|
||||||
|
--im-text-color: rgb(255 255 255 / 82%);
|
||||||
|
--im-text-color-grey: color: rgb(255 255 255 / 52%);
|
||||||
|
--im-active-bg-color: #2c2c32;
|
||||||
|
--im-hover-bg-color: #2c2c32;
|
||||||
|
|
||||||
|
--im-broadside-box-shadow: #201b1b;
|
||||||
|
|
||||||
|
// note
|
||||||
|
--im-note-list-bg-color: #2c2c32;
|
||||||
|
|
||||||
|
// 滚动条
|
||||||
|
--im-scrollbar: #e4e4e5;
|
||||||
|
--im-scrollbar-thumb: #625f5f;
|
||||||
|
|
||||||
|
// message
|
||||||
|
--im-message-bg-color: #28282c;
|
||||||
|
--im-message-border-color: rgb(255 255 255 / 9%);
|
||||||
|
--im-message-left-bg-color: #2c2c31;
|
||||||
|
--im-message-left-text-color: var(--im-text-color);
|
||||||
|
--im-message-right-bg-color: #2f2f38;
|
||||||
|
--im-message-right-bg-color: #35353f;
|
||||||
|
--im-message-right-text-color: var(--im-text-color);
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: #d3d3d3;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
::-moz-selection {
|
||||||
|
background: #d3d3d3;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
::-webkit-selection {
|
||||||
|
background: #d3d3d3;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--im-bg-color);
|
||||||
|
}
|
47
src/assets/css/dropsize.less
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
.dropsize-col-resize {
|
||||||
|
cursor: col-resize !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropsize-row-resize {
|
||||||
|
cursor: row-resize !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropsize-line {
|
||||||
|
position: absolute;
|
||||||
|
cursor: col-resize;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.dropsize-resizing {
|
||||||
|
background-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dropsize-line-top {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
width: 100%;
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dropsize-line-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
width: 100%;
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dropsize-line-left {
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dropsize-line-right {
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
96
src/assets/css/editor-mention.less
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
.ql-mention-list-container {
|
||||||
|
width: 160px;
|
||||||
|
max-height: 200px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(30, 30, 30, 0.08);
|
||||||
|
z-index: 9001;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 3px;
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-mention-loading {
|
||||||
|
line-height: 44px;
|
||||||
|
padding: 0 20px;
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-mention-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-mention-list-item {
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 0 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-mention-list-item.disabled {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-mention-list-item.selected {
|
||||||
|
background-color: var(--im-primary-color);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
height: 24px;
|
||||||
|
width: 65px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #d3e1eb;
|
||||||
|
padding: 3px 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
color: var(--im-primary-color);
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ed-member-item {
|
||||||
|
height: 35px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.ql-mention-list-container {
|
||||||
|
background-color: rgb(44 44 49);
|
||||||
|
color: #fff;
|
||||||
|
border: unset;
|
||||||
|
|
||||||
|
.ql-mention-list-item {
|
||||||
|
color: var(--im-text-color-grey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
169
src/assets/css/login.less
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
#logo-name {
|
||||||
|
position: fixed;
|
||||||
|
width: 200px;
|
||||||
|
height: 38px;
|
||||||
|
font-size: 34px;
|
||||||
|
font-family:
|
||||||
|
Times New Roman,
|
||||||
|
Georgia,
|
||||||
|
Serif;
|
||||||
|
color: #2196f3;
|
||||||
|
top: 20px;
|
||||||
|
left: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 30px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 70%;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b1a0a0;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #777272;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
position: fixed;
|
||||||
|
width: 350px;
|
||||||
|
min-height: 450px;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
padding: 10px 20px;
|
||||||
|
|
||||||
|
&.forget,
|
||||||
|
&.login {
|
||||||
|
height: 450px;
|
||||||
|
}
|
||||||
|
&.reister {
|
||||||
|
height: 485px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-header {
|
||||||
|
height: 38px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 38px;
|
||||||
|
margin: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 38px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-account {
|
||||||
|
padding: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
height: 25px;
|
||||||
|
line-height: 25px;
|
||||||
|
font-weight: 100;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-height: 500px) {
|
||||||
|
.copyright {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-box {
|
||||||
|
.fly {
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-fly-circle1 {
|
||||||
|
left: 40px;
|
||||||
|
top: 100px;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(100, 84, 239, 0.07) 0%,
|
||||||
|
rgba(48, 33, 236, 0.04) 100%
|
||||||
|
);
|
||||||
|
animation: move 2.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-fly-circle2 {
|
||||||
|
left: 3%;
|
||||||
|
top: 60%;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(100, 84, 239, 0.08) 0%,
|
||||||
|
rgba(48, 33, 236, 0.04) 100%
|
||||||
|
);
|
||||||
|
animation: move 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-fly-circle3 {
|
||||||
|
right: 2%;
|
||||||
|
top: 140px;
|
||||||
|
width: 145px;
|
||||||
|
height: 145px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(to right, rgba(100, 84, 239, 0.1) 0%, rgba(48, 33, 236, 0.04) 100%);
|
||||||
|
animation: move 2.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-fly-circle4 {
|
||||||
|
right: 5%;
|
||||||
|
top: 60%;
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(100, 84, 239, 0.02) 0%,
|
||||||
|
rgba(48, 33, 236, 0.04) 100%
|
||||||
|
);
|
||||||
|
animation: move 3.5s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes move {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.login-box {
|
||||||
|
border: 1px solid #393939;
|
||||||
|
}
|
||||||
|
}
|
58
src/assets/css/settting.less
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
.title {
|
||||||
|
height: 60px;
|
||||||
|
line-height: 60px;
|
||||||
|
padding-left: 15px;
|
||||||
|
color: var(--im-text-color);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-box {
|
||||||
|
padding: 15px;
|
||||||
|
padding-top: 0;
|
||||||
|
|
||||||
|
.view-list {
|
||||||
|
height: 70px;
|
||||||
|
margin: 5px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 80px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: auto;
|
||||||
|
color: var(--im-text-color);
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 15px;
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #989898;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
width: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
src/assets/fonts/AlibabaPuHuiTi/AlibabaPuHuiTi_2_45_Light.woff
Normal file
BIN
src/assets/fonts/AlibabaPuHuiTi/AlibabaPuHuiTi_2_45_Light.woff2
Normal file
BIN
src/assets/image/0A039CDF.png
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
src/assets/image/avatar.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/image/background.jpeg
Normal file
After Width: | Height: | Size: 588 KiB |
1
src/assets/image/empty.svg
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/image/favicon.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
src/assets/image/gitee-avatar.jpg
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/image/github-avatar.jpg
Normal file
After Width: | Height: | Size: 12 KiB |
1
src/assets/image/md.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24" fill="none"><defs><rect id="path_0" x="0" y="0" width="24" height="24" /></defs><g opacity="1" transform="translate(0 0) rotate(0 12 12)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" fill-rule="evenodd" style="fill:#A6A6A6" transform="translate(7.5 7.500003730952395) rotate(0 4.5 4.500548134523802)" opacity="1" d="M1.8,8.1L1.8,4.27L3.67,7.65C3.82,7.95 4.15,8.12 4.5,8.1C4.85,8.12 5.18,7.95 5.33,7.65L7.2,4.27L7.2,8.1C7.2,8.6 7.6,9 8.1,9C8.6,9 9,8.6 9,8.1L9,0.9C9,0.41 8.6,0 8.1,0C8.1,0 8.09,0 8.09,0C7.75,-0.02 7.42,0.15 7.27,0.46L4.5,5.45L1.73,0.45C1.58,0.15 1.25,-0.02 0.91,0C0.91,0 0.9,0 0.9,0C0.43,0 0,0.4 0,0.9L0,8.1C0,8.6 0.4,9 0.9,9C1.4,9 1.8,8.6 1.8,8.1Z " /><path id="分组 1" fill-rule="evenodd" style="fill:#A6A6A6" transform="translate(4 4) rotate(0 8 8)" opacity="1" d="M0 2L0 14C0 15.11 0.89 16 2 16L14 16C15.11 16 16 15.11 16 14L16 2C16 0.89 15.11 0 14 0L2 0C0.89 0 0 0.89 0 2Z M14.75 13.79L14.75 2.31C14.75 1.78 14.32 1.35 13.79 1.35L2.31 1.35C1.78 1.35 1.35 1.78 1.35 2.31L1.35 13.79C1.35 14.32 1.78 14.75 2.31 14.75L13.79 14.75C14.32 14.75 14.75 14.32 14.75 13.79Z " /></g></g></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
src/assets/image/no-data.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg width="64" height="41" viewBox="0 0 64 41" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" fill="currentColor" class="svg-icon"><g transform="translate(0 1)" fill="none" fillRule="evenodd"><ellipse fill="#F5F5F5" cx="32" cy="33" rx="32" ry="7"></ellipse><g fillRule="nonzero" stroke="#D9D9D9"><path d="M55 12.76L44.854 1.258C44.367.474 43.656 0 42.907 0H21.093c-.749 0-1.46.474-1.947 1.257L9 12.761V22h46v-9.24z"></path><path d="M41.613 15.931c0-1.605.994-2.93 2.227-2.931H55v18.137C55 33.26 53.68 35 52.05 35h-40.1C10.32 35 9 33.259 9 31.137V13h11.16c1.233 0 2.227 1.323 2.227 2.928v.022c0 1.605 1.005 2.901 2.237 2.901h14.752c1.232 0 2.237-1.308 2.237-2.913v-.007z" fill="#FAFAFA"></path></g></g></svg>
|
After Width: | Height: | Size: 715 B |
1857
src/assets/image/not-found.svg
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
src/assets/image/notify.png
Normal file
After Width: | Height: | Size: 11 KiB |
1
src/assets/image/welcome.svg
Normal file
After Width: | Height: | Size: 205 KiB |
BIN
src/assets/music.mp3
Normal file
41
src/components/base/Avatar.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { hashStrToHexColor } from '@/utils/common'
|
||||||
|
import { defAvatar } from '@/constant/default'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 30
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 14
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-avatar v-if="src.length" round :src="src" :size="size" :fallback-src="defAvatar" />
|
||||||
|
|
||||||
|
<n-avatar
|
||||||
|
v-else
|
||||||
|
round
|
||||||
|
:style="{
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: hashStrToHexColor(username || ''),
|
||||||
|
fontSize: fontSize + 'px'
|
||||||
|
}"
|
||||||
|
:size="size"
|
||||||
|
>
|
||||||
|
{{ username && username.substring(0, 1) }}
|
||||||
|
</n-avatar>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped></style>
|
225
src/components/base/AvatarCropper.vue
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { NModal, NCard } from 'naive-ui'
|
||||||
|
import { Close, UploadOne, RefreshOne, Redo, Undo } from '@icon-park/vue-next'
|
||||||
|
import 'vue-cropper/dist/index.css'
|
||||||
|
import { VueCropper } from 'vue-cropper'
|
||||||
|
import { ServeUploadAvatar } from '@/api/upload'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'success'])
|
||||||
|
const state = reactive({
|
||||||
|
show: true,
|
||||||
|
src: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const cropper = ref('cropper')
|
||||||
|
|
||||||
|
const option = reactive({
|
||||||
|
img: '',
|
||||||
|
size: 1,
|
||||||
|
full: false,
|
||||||
|
outputType: 'png',
|
||||||
|
canMove: true,
|
||||||
|
fixedBox: true,
|
||||||
|
original: false,
|
||||||
|
canMoveBox: true,
|
||||||
|
autoCrop: true,
|
||||||
|
autoCropWidth: 250,
|
||||||
|
autoCropHeight: 250,
|
||||||
|
centerBox: false,
|
||||||
|
high: true,
|
||||||
|
preview: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTriggerUpload() {
|
||||||
|
document.getElementById('upload-avatar').click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpload = (e) => {
|
||||||
|
let file = e.target.files[0]
|
||||||
|
|
||||||
|
let reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
let data
|
||||||
|
if (typeof e.target.result === 'object') {
|
||||||
|
// 把Array Buffer转化为blob 如果是base64不需要
|
||||||
|
data = window.URL.createObjectURL(new Blob([e.target.result]))
|
||||||
|
|
||||||
|
console.log(data, e.target.result)
|
||||||
|
} else {
|
||||||
|
data = e.target.result
|
||||||
|
}
|
||||||
|
|
||||||
|
option.img = data
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const realTime = (data) => {
|
||||||
|
cropper.value.getCropData((img) => {
|
||||||
|
option.preview = img
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotateLeft = () => {
|
||||||
|
cropper.value.rotateLeft()
|
||||||
|
}
|
||||||
|
const rotateRight = () => {
|
||||||
|
cropper.value.rotateRight()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshCrop = () => {
|
||||||
|
cropper.value.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
cropper.value.getCropBlob((blob) => {
|
||||||
|
let file = new File([blob], 'avatar.png', {
|
||||||
|
type: blob.type,
|
||||||
|
lastModified: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
|
||||||
|
ServeUploadAvatar(form).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
emit('success', res.data.avatar)
|
||||||
|
} else {
|
||||||
|
window['$message'].info(res.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
id="upload-avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||||
|
@change="onUpload"
|
||||||
|
/>
|
||||||
|
<n-modal v-model:show="state.show" :on-after-leave="onMaskClick">
|
||||||
|
<n-card style="width: 800px" title="选择头像" :bordered="false" class="modal-radius">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-icon size="22" :component="Close" @click="state.show = false" class="pointer" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="canvas">
|
||||||
|
<vue-cropper
|
||||||
|
ref="cropper"
|
||||||
|
:img="option.img"
|
||||||
|
:output-size="option.size"
|
||||||
|
:output-type="option.outputType"
|
||||||
|
:info="true"
|
||||||
|
:full="option.full"
|
||||||
|
:can-move="option.canMove"
|
||||||
|
:can-move-box="option.canMoveBox"
|
||||||
|
:fixed-box="option.fixedBox"
|
||||||
|
:original="option.original"
|
||||||
|
:auto-crop="option.autoCrop"
|
||||||
|
:auto-crop-width="option.autoCropWidth"
|
||||||
|
:auto-crop-height="option.autoCropHeight"
|
||||||
|
:center-box="option.centerBox"
|
||||||
|
@real-time="realTime"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="view">
|
||||||
|
<div class="preview">
|
||||||
|
<img :src="option.preview" v-show="option.preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<section class="el-container" style="height: 38px">
|
||||||
|
<aside
|
||||||
|
class="el-aside"
|
||||||
|
style="
|
||||||
|
width: 400px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<n-button @click="onTriggerUpload" type="primary" ghost>
|
||||||
|
上传图片
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="UploadOne" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button @click="refreshCrop">
|
||||||
|
重置
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="RefreshOne" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button @click="rotateLeft">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="Undo" />
|
||||||
|
</template>
|
||||||
|
左转
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button @click="rotateRight">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="Redo" />
|
||||||
|
</template>
|
||||||
|
右转
|
||||||
|
</n-button>
|
||||||
|
</aside>
|
||||||
|
<main class="el-main" style="text-align: center">
|
||||||
|
<n-button type="primary" @click="onSubmit">保存头像</n-button>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
#upload-avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
flex: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
305
src/components/base/Loading.vue
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
<script setup></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="ant-spin ant-spin-lg ant-spin-spinning">
|
||||||
|
<span class="ant-spin-dot ant-spin-dot-spin">
|
||||||
|
<i class="ant-spin-dot-item" />
|
||||||
|
<i class="ant-spin-dot-item" />
|
||||||
|
<i class="ant-spin-dot-item" />
|
||||||
|
<i class="ant-spin-dot-item" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p>数据加载中...</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.loading-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 60%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
p {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: rgb(194 194 194);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ant-spin 加载动画 start */
|
||||||
|
.ant-spin {
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant: tabular-nums;
|
||||||
|
line-height: 1.5715;
|
||||||
|
list-style: none;
|
||||||
|
-webkit-font-feature-settings: 'tnum';
|
||||||
|
font-feature-settings: 'tnum';
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
color: #1890ff;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||||
|
transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||||
|
transition:
|
||||||
|
transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
|
||||||
|
-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-spinning {
|
||||||
|
position: static;
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin .ant-spin-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
margin: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin .ant-spin-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 5px;
|
||||||
|
text-shadow: 0 1px 2px #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin.ant-spin-show-text .ant-spin-dot {
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin-sm .ant-spin-dot {
|
||||||
|
margin: -7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin-sm .ant-spin-text {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin-sm.ant-spin-show-text .ant-spin-dot {
|
||||||
|
margin-top: -17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin-lg .ant-spin-dot {
|
||||||
|
margin: -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin-lg .ant-spin-text {
|
||||||
|
padding-top: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-nested-loading > div > .ant-spin-lg.ant-spin-show-text .ant-spin-dot {
|
||||||
|
margin-top: -26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-container {
|
||||||
|
position: relative;
|
||||||
|
-webkit-transition: opacity 0.3s;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-container::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: none \9;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: all 0.3s;
|
||||||
|
transition: all 0.3s;
|
||||||
|
content: '';
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-blur {
|
||||||
|
clear: both;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.5;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-blur::after {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-tip {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background-color: #1890ff;
|
||||||
|
border-radius: 100%;
|
||||||
|
-webkit-transform: scale(0.75);
|
||||||
|
transform: scale(0.75);
|
||||||
|
-webkit-transform-origin: 50% 50%;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
opacity: 0.3;
|
||||||
|
-webkit-animation: antSpinMove 1s infinite linear alternate;
|
||||||
|
animation: antSpinMove 1s infinite linear alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item:nth-child(1) {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item:nth-child(2) {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
-webkit-animation-delay: 0.4s;
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item:nth-child(3) {
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
-webkit-animation-delay: 0.8s;
|
||||||
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-item:nth-child(4) {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
-webkit-animation-delay: 1.2s;
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-dot-spin {
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
-webkit-animation: antRotate 1.2s infinite linear;
|
||||||
|
animation: antRotate 1.2s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-sm .ant-spin-dot {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-sm .ant-spin-dot i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-lg .ant-spin-dot {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-lg .ant-spin-dot i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin.ant-spin-show-text .ant-spin-text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||||
|
.ant-spin-blur {
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes antSpinMove {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antSpinMove {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes antRotate {
|
||||||
|
to {
|
||||||
|
-webkit-transform: rotate(405deg);
|
||||||
|
transform: rotate(405deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antRotate {
|
||||||
|
to {
|
||||||
|
-webkit-transform: rotate(405deg);
|
||||||
|
transform: rotate(405deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-spin-rtl .ant-spin-dot-spin {
|
||||||
|
-webkit-transform: rotate(-45deg);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
-webkit-animation-name: antRotateRtl;
|
||||||
|
animation-name: antRotateRtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes antRotateRtl {
|
||||||
|
to {
|
||||||
|
-webkit-transform: rotate(-405deg);
|
||||||
|
transform: rotate(-405deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antRotateRtl {
|
||||||
|
to {
|
||||||
|
-webkit-transform: rotate(-405deg);
|
||||||
|
transform: rotate(-405deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ant-spin 加载动画 end */
|
||||||
|
</style>
|
164
src/components/base/UploadsModal.vue
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<script setup>
|
||||||
|
import { NProgress } from 'naive-ui'
|
||||||
|
import { useUploadsStore } from '@/store'
|
||||||
|
import { fileFormatSize } from '@/utils/strings'
|
||||||
|
|
||||||
|
const uploadsStore = useUploadsStore()
|
||||||
|
|
||||||
|
const statusItem = {
|
||||||
|
0: '等待上传',
|
||||||
|
1: '上传中',
|
||||||
|
2: '上传完成',
|
||||||
|
3: '网络异常'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="section me-scrollbar me-scrollbar-thumb">
|
||||||
|
<div class="title bdr-b">
|
||||||
|
<span>上传管理 ({{ uploadsStore.successCount }}/{{ uploadsStore.items.length }})</span>
|
||||||
|
<span class="pointer" @click="uploadsStore.close()">关闭</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-item" v-for="item in uploadsStore.items" :key="item.upload_id">
|
||||||
|
<div class="file-header">
|
||||||
|
<div class="type-icon flex-center">
|
||||||
|
{{ item.username.substr(0, 1) }}
|
||||||
|
</div>
|
||||||
|
<div class="filename">{{ item.username }}</div>
|
||||||
|
<div class="status">
|
||||||
|
<span :class="{ success: item.status == 2 }">
|
||||||
|
{{ statusItem[item.status] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-mian">
|
||||||
|
<div class="progress flex-center">
|
||||||
|
<n-progress
|
||||||
|
style="width: 60px; height: 60px"
|
||||||
|
type="circle"
|
||||||
|
:percentage="item.percentage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="detail">
|
||||||
|
<p>
|
||||||
|
名称:<span>{{ item.file.name }}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
类型:<span>{{ item.file.type || 'text' }}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
大小:<span>{{ fileFormatSize(item.file.size) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.section {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
width: 95%;
|
||||||
|
min-height: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 15px auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--im-message-border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
.file-header {
|
||||||
|
height: 45px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--im-message-border-color);
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
background-color: rgb(80, 138, 254);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 200;
|
||||||
|
overflow: hidden;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 65%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b6868;
|
||||||
|
font-weight: 200;
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: rgb(103, 194, 58);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-mian {
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
flex: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-left: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 3px;
|
||||||
|
color: #ada8a8;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #595a5a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-progress-text) {
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
</style>
|
41
src/components/base/Xtime.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script>
|
||||||
|
import { defineComponent, h, ref, onUnmounted, watch } from 'vue'
|
||||||
|
import { beautifyTime } from '@/utils/datetime'
|
||||||
|
|
||||||
|
// 时间组件
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'Xtime',
|
||||||
|
props: {
|
||||||
|
time: {
|
||||||
|
type: String,
|
||||||
|
default: '2022-03-06 21:20:00'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
let timeout = null
|
||||||
|
|
||||||
|
const inTime = new Date(props.time.replace(/-/g, '/')).getTime()
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
|
||||||
|
const format = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
text.value = beautifyTime(props.time)
|
||||||
|
if (new Date().getTime() - inTime < 30 * 60 * 1000) {
|
||||||
|
timeout = setTimeout(format, 60 * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(props, format)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
format()
|
||||||
|
|
||||||
|
return () => h('span', [text.value])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
20
src/components/common/DialogApi.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, getCurrentInstance } from 'vue'
|
||||||
|
import { useDialog } from 'naive-ui'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
setup() {
|
||||||
|
const ctx = getCurrentInstance()
|
||||||
|
if (ctx) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
window['$dialog'] = dialog
|
||||||
|
|
||||||
|
ctx.appContext.config.globalProperties.$dialog = dialog
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
19
src/components/common/MessageApi.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, getCurrentInstance } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
setup() {
|
||||||
|
const ctx = getCurrentInstance()
|
||||||
|
if (ctx) {
|
||||||
|
const message = useMessage()
|
||||||
|
window['$message'] = message
|
||||||
|
|
||||||
|
ctx.appContext.config.globalProperties.$message = message
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
20
src/components/common/NotificationApi.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, getCurrentInstance } from 'vue'
|
||||||
|
import { useNotification } from 'naive-ui'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
setup() {
|
||||||
|
const ctx = getCurrentInstance()
|
||||||
|
if (ctx) {
|
||||||
|
const notification = useNotification()
|
||||||
|
|
||||||
|
window['$notification'] = notification
|
||||||
|
|
||||||
|
ctx.appContext.config.globalProperties.$notification = notification
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
5
src/components/common/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import DialogApi from './DialogApi.vue'
|
||||||
|
import MessageApi from './MessageApi.vue'
|
||||||
|
import NotificationApi from './NotificationApi.vue'
|
||||||
|
|
||||||
|
export { DialogApi, MessageApi, NotificationApi }
|
703
src/components/editor/Editor.vue
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import '@vueup/vue-quill/dist/vue-quill.snow.css'
|
||||||
|
import 'quill-image-uploader/dist/quill.imageUploader.min.css'
|
||||||
|
import '@/assets/css/editor-mention.less'
|
||||||
|
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { NPopover } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
Voice as IconVoice,
|
||||||
|
SourceCode,
|
||||||
|
Local,
|
||||||
|
SmilingFace,
|
||||||
|
Pic,
|
||||||
|
FolderUpload,
|
||||||
|
Ranking,
|
||||||
|
History
|
||||||
|
} from '@icon-park/vue-next'
|
||||||
|
import { QuillEditor, Quill } from '@vueup/vue-quill'
|
||||||
|
import ImageUploader from 'quill-image-uploader'
|
||||||
|
import EmojiBlot from './formats/emoji'
|
||||||
|
import QuoteBlot from './formats/quote'
|
||||||
|
import 'quill-mention'
|
||||||
|
import { useDialogueStore, useEditorDraftStore } from '@/store'
|
||||||
|
import { deltaToMessage, deltaToString, isEmptyDelta } from './util'
|
||||||
|
import { getImageInfo } from '@/utils/functions'
|
||||||
|
import { EditorConst } from '@/constant/event-bus'
|
||||||
|
import { emitCall } from '@/utils/common'
|
||||||
|
import { defAvatar } from '@/constant/default'
|
||||||
|
import MeEditorVote from './MeEditorVote.vue'
|
||||||
|
import MeEditorEmoticon from './MeEditorEmoticon.vue'
|
||||||
|
import MeEditorCode from './MeEditorCode.vue'
|
||||||
|
import MeEditorRecorder from './MeEditorRecorder.vue'
|
||||||
|
import { ServeUploadImage } from '@/api/upload'
|
||||||
|
import { uploadImg } from '@/api/upload'
|
||||||
|
import { useEventBus } from '@/hooks'
|
||||||
|
|
||||||
|
Quill.register('formats/emoji', EmojiBlot)
|
||||||
|
Quill.register('formats/quote', QuoteBlot)
|
||||||
|
Quill.register('modules/imageUploader', ImageUploader)
|
||||||
|
|
||||||
|
const emit = defineEmits(['editor-event'])
|
||||||
|
const dialogueStore = useDialogueStore()
|
||||||
|
const editorDraftStore = useEditorDraftStore()
|
||||||
|
const props = defineProps({
|
||||||
|
vote: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const editor = ref()
|
||||||
|
|
||||||
|
const getQuill = () => {
|
||||||
|
return editor.value?.getQuill()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getQuillSelectionIndex = () => {
|
||||||
|
let quill = getQuill()
|
||||||
|
|
||||||
|
return (quill.getSelection() || {}).index || quill.getLength()
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexName = computed(() => dialogueStore.index_name)
|
||||||
|
const isShowEditorVote = ref(false)
|
||||||
|
const isShowEditorCode = ref(false)
|
||||||
|
const isShowEditorRecorder = ref(false)
|
||||||
|
const fileImageRef = ref()
|
||||||
|
const uploadFileRef = ref()
|
||||||
|
const emoticonRef = ref()
|
||||||
|
|
||||||
|
const editorOption = {
|
||||||
|
debug: false,
|
||||||
|
modules: {
|
||||||
|
toolbar: false,
|
||||||
|
clipboard: {
|
||||||
|
// 粘贴版,处理粘贴时候的自带样式
|
||||||
|
matchers: [[Node.ELEMENT_NODE, onClipboardMatcher]]
|
||||||
|
},
|
||||||
|
|
||||||
|
keyboard: {
|
||||||
|
bindings: {
|
||||||
|
enter: {
|
||||||
|
key: 13,
|
||||||
|
handler: onSendMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
imageUploader: {
|
||||||
|
upload: onEditorUpload
|
||||||
|
},
|
||||||
|
|
||||||
|
mention: {
|
||||||
|
allowedChars: /^[\u4e00-\u9fa5]*$/,
|
||||||
|
mentionDenotationChars: ['@'],
|
||||||
|
positioningStrategy: 'fixed',
|
||||||
|
renderItem: (data: any) => {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'ed-member-item'
|
||||||
|
el.innerHTML = `<img src="${data.avatar}" class="avator"/>`
|
||||||
|
el.innerHTML += `<span class="nickname">${data.nickname}</span>`
|
||||||
|
return el
|
||||||
|
},
|
||||||
|
source: function (searchTerm: string, renderList: any) {
|
||||||
|
if (!props.members.length) {
|
||||||
|
return renderList([])
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = [
|
||||||
|
{ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' },
|
||||||
|
...props.members
|
||||||
|
]
|
||||||
|
|
||||||
|
const items = list.filter(
|
||||||
|
(item: any) => item.nickname.toLowerCase().indexOf(searchTerm) !== -1
|
||||||
|
)
|
||||||
|
|
||||||
|
renderList(items)
|
||||||
|
},
|
||||||
|
mentionContainerClass: 'ql-mention-list-container me-scrollbar me-scrollbar-thumb'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder: '按Enter发送 / Shift+Enter 换行',
|
||||||
|
theme: 'snow'
|
||||||
|
}
|
||||||
|
|
||||||
|
const navs = reactive([
|
||||||
|
{
|
||||||
|
title: '图片',
|
||||||
|
icon: markRaw(Pic),
|
||||||
|
show: true,
|
||||||
|
click: () => {
|
||||||
|
fileImageRef.value.click()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '附件',
|
||||||
|
icon: markRaw(FolderUpload),
|
||||||
|
show: true,
|
||||||
|
click: () => {
|
||||||
|
uploadFileRef.value.click()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '代码',
|
||||||
|
icon: markRaw(SourceCode),
|
||||||
|
show: true,
|
||||||
|
click: () => {
|
||||||
|
isShowEditorCode.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '语音消息',
|
||||||
|
icon: markRaw(IconVoice),
|
||||||
|
show: true,
|
||||||
|
click: () => {
|
||||||
|
isShowEditorRecorder.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '地理位置',
|
||||||
|
icon: markRaw(Local),
|
||||||
|
show: true,
|
||||||
|
click: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '群投票',
|
||||||
|
icon: markRaw(Ranking),
|
||||||
|
show: computed(() => props.vote),
|
||||||
|
click: () => {
|
||||||
|
isShowEditorVote.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '历史记录',
|
||||||
|
icon: markRaw(History),
|
||||||
|
show: true,
|
||||||
|
click: () => {
|
||||||
|
emit('editor-event', emitCall('history_event'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
function onUploadImage(file: File) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let image = new Image()
|
||||||
|
image.src = URL.createObjectURL(file)
|
||||||
|
image.onload = () => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
form.append("source", "fonchain-chat");
|
||||||
|
// form.append('width', image.width.toString())
|
||||||
|
// form.append('height', image.height.toString())
|
||||||
|
form.append("urlParam", `width=${image.width}&height=${image.height}`);
|
||||||
|
|
||||||
|
uploadImg(form).then(({ code, data, message }) => {
|
||||||
|
if (code == 0) {
|
||||||
|
resolve(data.ori_url)
|
||||||
|
} else {
|
||||||
|
resolve('')
|
||||||
|
window['$message'].error(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditorUpload(file: File) {
|
||||||
|
async function fn(file: File, resolve: Function, reject: Function) {
|
||||||
|
if (file.type.indexOf('image/') === 0) {
|
||||||
|
return resolve(await onUploadImage(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
reject()
|
||||||
|
|
||||||
|
if (file.type.indexOf('video/') === 0) {
|
||||||
|
let fn = emitCall('video_event', file, () => {})
|
||||||
|
emit('editor-event', fn)
|
||||||
|
} else {
|
||||||
|
let fn = emitCall('file_event', file, () => {})
|
||||||
|
emit('editor-event', fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fn(file, resolve, reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVoteEvent(data: any) {
|
||||||
|
const msg = emitCall('vote_event', data, (ok: boolean) => {
|
||||||
|
if (ok) {
|
||||||
|
isShowEditorVote.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('editor-event', msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEmoticonEvent(data: any) {
|
||||||
|
emoticonRef.value.setShow(false)
|
||||||
|
|
||||||
|
if (data.type == 1) {
|
||||||
|
const quill = getQuill()
|
||||||
|
let index = getQuillSelectionIndex()
|
||||||
|
|
||||||
|
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
|
||||||
|
quill.deleteText(0, 1)
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.img) {
|
||||||
|
quill.insertEmbed(index, 'emoji', {
|
||||||
|
alt: data.value,
|
||||||
|
src: data.img,
|
||||||
|
width: '24px',
|
||||||
|
height: '24px'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
quill.insertText(index, data.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
quill.setSelection(index + 1, 0, 'user')
|
||||||
|
} else {
|
||||||
|
let fn = emitCall('emoticon_event', data.value, () => {})
|
||||||
|
emit('editor-event', fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCodeEvent(data: any) {
|
||||||
|
const msg = emitCall('code_event', data, (ok: boolean) => {
|
||||||
|
isShowEditorCode.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('editor-event', msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUploadFile(e: any) {
|
||||||
|
let file = e.target.files[0]
|
||||||
|
|
||||||
|
e.target.value = null
|
||||||
|
|
||||||
|
console.log("文件类型"+file.type)
|
||||||
|
if (file.type.indexOf('image/') === 0) {
|
||||||
|
console.log("进入图片")
|
||||||
|
const quill = getQuill()
|
||||||
|
let index = getQuillSelectionIndex()
|
||||||
|
|
||||||
|
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
|
||||||
|
quill.deleteText(0, 1)
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let src = await onUploadImage(file)
|
||||||
|
if (src) {
|
||||||
|
quill.insertEmbed(index, 'image', src)
|
||||||
|
quill.setSelection(index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type.indexOf('video/') === 0) {
|
||||||
|
console.log("进入视频")
|
||||||
|
let fn = emitCall('video_event', file, () => {})
|
||||||
|
emit('editor-event', fn)
|
||||||
|
} else {
|
||||||
|
console.log("进入其他")
|
||||||
|
let fn = emitCall('file_event', file, () => {})
|
||||||
|
emit('editor-event', fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRecorderEvent(file: any) {
|
||||||
|
emit('editor-event', emitCall('file_event', file))
|
||||||
|
isShowEditorRecorder.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClipboardMatcher(node: any, Delta) {
|
||||||
|
const ops: any[] = []
|
||||||
|
|
||||||
|
Delta.ops.forEach((op) => {
|
||||||
|
// 如果粘贴了图片,这里会是一个对象,所以可以这样处理
|
||||||
|
if (op.insert && typeof op.insert === 'string') {
|
||||||
|
ops.push({
|
||||||
|
insert: op.insert, // 文字内容
|
||||||
|
attributes: {} //文字样式(包括背景色和文字颜色等)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ops.push(op)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Delta.ops = ops
|
||||||
|
return Delta
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSendMessage() {
|
||||||
|
var delta = getQuill().getContents()
|
||||||
|
let data = deltaToMessage(delta)
|
||||||
|
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (data.msgType) {
|
||||||
|
case 1: // 文字消息
|
||||||
|
if (data.items[0].content.length > 1024) {
|
||||||
|
return window['$message'].info('发送内容超长,请分条发送')
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(
|
||||||
|
'editor-event',
|
||||||
|
emitCall('text_event', data, (ok: any) => {
|
||||||
|
ok && getQuill().setContents([], Quill.sources.USER)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 3: // 图片消息
|
||||||
|
emit(
|
||||||
|
'editor-event',
|
||||||
|
emitCall(
|
||||||
|
'image_event',
|
||||||
|
{ ...getImageInfo(data.items[0].content), url: data.items[0].content, size: 10000 },
|
||||||
|
(ok: any) => {
|
||||||
|
ok && getQuill().setContents([])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 12: // 图文消息
|
||||||
|
emit(
|
||||||
|
'editor-event',
|
||||||
|
emitCall('mixed_event', data, (ok: any) => {
|
||||||
|
ok && getQuill().setContents([])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditorChange() {
|
||||||
|
let delta = getQuill().getContents()
|
||||||
|
|
||||||
|
let text = deltaToString(delta)
|
||||||
|
|
||||||
|
if (!isEmptyDelta(delta)) {
|
||||||
|
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
|
||||||
|
text: text,
|
||||||
|
ops: delta.ops
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 删除 editorDraftStore.items 下的元素
|
||||||
|
delete editorDraftStore.items[indexName.value || '']
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('editor-event', emitCall('input_event', text))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEditorDraftText() {
|
||||||
|
if (!editor.value) return
|
||||||
|
|
||||||
|
// 这里延迟处理,不然会有问题
|
||||||
|
setTimeout(() => {
|
||||||
|
hideMentionDom()
|
||||||
|
|
||||||
|
const quill = getQuill()
|
||||||
|
|
||||||
|
if (!quill) return
|
||||||
|
|
||||||
|
// 从缓存中加载编辑器草稿
|
||||||
|
let draft = editorDraftStore.items[indexName.value || '']
|
||||||
|
if (draft) {
|
||||||
|
quill.setContents(JSON.parse(draft)?.ops || [])
|
||||||
|
} else {
|
||||||
|
quill.setContents([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = getQuillSelectionIndex()
|
||||||
|
quill.setSelection(index, 0, 'user')
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubscribeMention(data: any) {
|
||||||
|
const mention = getQuill().getModule('mention')
|
||||||
|
|
||||||
|
mention.insertItem({ id: data?.id, denotationChar: '@', value: data.value }, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubscribeQuote(data: any) {
|
||||||
|
const delta = getQuill().getContents()
|
||||||
|
if (delta.ops?.some((item: any) => item.insert.quote)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const quill = getQuill()
|
||||||
|
const index = getQuillSelectionIndex()
|
||||||
|
|
||||||
|
quill.insertEmbed(0, 'quote', data)
|
||||||
|
quill.setSelection(index + 1, 0, 'user')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMentionDom() {
|
||||||
|
let el = document.querySelector('.ql-mention-list-container')
|
||||||
|
if (el) {
|
||||||
|
document.querySelector('body')?.removeChild(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(indexName, loadEditorDraftText, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadEditorDraftText()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
hideMentionDom()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEventBus([
|
||||||
|
{ name: EditorConst.Mention, event: onSubscribeMention },
|
||||||
|
{ name: EditorConst.Quote, event: onSubscribeQuote }
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="el-container editor">
|
||||||
|
<section class="el-container is-vertical">
|
||||||
|
<header class="el-header toolbar bdr-t">
|
||||||
|
<div class="tools">
|
||||||
|
<n-popover
|
||||||
|
placement="top-start"
|
||||||
|
trigger="click"
|
||||||
|
raw
|
||||||
|
:show-arrow="false"
|
||||||
|
:width="300"
|
||||||
|
ref="emoticonRef"
|
||||||
|
style="width: 500px; height: 250px; border-radius: 10px; overflow: hidden"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<div class="item pointer">
|
||||||
|
<n-icon size="18" class="icon" :component="SmilingFace" />
|
||||||
|
<p class="tip-title">表情符号</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MeEditorEmoticon @on-select="onEmoticonEvent" />
|
||||||
|
</n-popover>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="item pointer"
|
||||||
|
v-for="nav in navs"
|
||||||
|
:key="nav.title"
|
||||||
|
v-show="nav.show"
|
||||||
|
@click="nav.click"
|
||||||
|
>
|
||||||
|
<n-icon size="18" class="icon" :component="nav.icon" />
|
||||||
|
<p class="tip-title">{{ nav.title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="el-main height100">
|
||||||
|
<QuillEditor
|
||||||
|
ref="editor"
|
||||||
|
id="editor"
|
||||||
|
:options="editorOption"
|
||||||
|
@editorChange="onEditorChange"
|
||||||
|
style="height: 100%; border: none"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form enctype="multipart/form-data" style="display: none">
|
||||||
|
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" />
|
||||||
|
<input type="file" ref="uploadFileRef" @change="onUploadFile" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" />
|
||||||
|
|
||||||
|
<MeEditorCode
|
||||||
|
v-if="isShowEditorCode"
|
||||||
|
@on-submit="onCodeEvent"
|
||||||
|
@close="isShowEditorCode = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MeEditorRecorder
|
||||||
|
v-if="isShowEditorRecorder"
|
||||||
|
@on-submit="onRecorderEvent"
|
||||||
|
@close="isShowEditorRecorder = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.editor {
|
||||||
|
--tip-bg-color: rgb(241 241 241 / 90%);
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
height: 100%;
|
||||||
|
flex: auto;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 35px;
|
||||||
|
margin: 0 2px;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.tip-title {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
left: 0px;
|
||||||
|
line-height: 26px;
|
||||||
|
background-color: var(--tip-bg-color);
|
||||||
|
color: var(--im-text-color);
|
||||||
|
min-width: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
white-space: pre;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 999999999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.tip-title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.editor {
|
||||||
|
--tip-bg-color: #48484d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
#editor {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-editor {
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--im-scrollbar-thumb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-editor.ql-blank::before {
|
||||||
|
font-family:
|
||||||
|
PingFang SC,
|
||||||
|
Microsoft YaHei,
|
||||||
|
'Alibaba PuHuiTi 2.0 45' !important;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-editor img {
|
||||||
|
max-width: 100px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #48484d;
|
||||||
|
margin: 0px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-uploading {
|
||||||
|
display: flex;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
filter: unset;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ed-emoji {
|
||||||
|
background-color: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-editor.ql-blank::before {
|
||||||
|
font-style: unset;
|
||||||
|
color: #b8b3b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-card-content {
|
||||||
|
display: flex;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.quote-card-title {
|
||||||
|
height: 22px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.quote-card-remove {
|
||||||
|
margin-right: 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-card-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.ql-editor.ql-blank::before {
|
||||||
|
color: #57575a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-card-content {
|
||||||
|
background-color: var(--im-message-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
122
src/components/editor/MeEditorCode.vue
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { NModal, NInput, NPopselect } from 'naive-ui'
|
||||||
|
import { options } from '@/constant/highlight.js'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'on-submit'])
|
||||||
|
|
||||||
|
const isShowBox = ref(true)
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
lang: '',
|
||||||
|
code: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const langText = computed(() => {
|
||||||
|
let data = options.find((item) => {
|
||||||
|
return item.value == model.lang
|
||||||
|
})
|
||||||
|
|
||||||
|
return data ? data.label : '请选择语言类型'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCanSubmit = computed(() => {
|
||||||
|
return !(model.lang && model.code)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
let data = {
|
||||||
|
lang: model.lang,
|
||||||
|
code: model.code
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.lang == 'json') {
|
||||||
|
try {
|
||||||
|
data.code = JSON.stringify(JSON.parse(model.code), null, 2)
|
||||||
|
} catch (error) {
|
||||||
|
data.code = model.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('on-submit', data)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShowBox"
|
||||||
|
preset="card"
|
||||||
|
title="代码消息"
|
||||||
|
class="modal-radius"
|
||||||
|
style="max-width: 800px; height: 600px"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
:segmented="{
|
||||||
|
content: true
|
||||||
|
}"
|
||||||
|
:mask-closable="false"
|
||||||
|
>
|
||||||
|
<div class="preview" id="add-content">
|
||||||
|
<div class="popselect">
|
||||||
|
<span>语言类型:</span>
|
||||||
|
|
||||||
|
<n-popselect v-model:value="model.lang" :options="options" size="medium" scrollable>
|
||||||
|
<n-button text type="primary">
|
||||||
|
{{ langText }}
|
||||||
|
</n-button>
|
||||||
|
</n-popselect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:maxlength="65535"
|
||||||
|
show-count
|
||||||
|
style="height: 380px"
|
||||||
|
placeholder="请输入..."
|
||||||
|
v-model:value="model.code"
|
||||||
|
>
|
||||||
|
<template #count="{ value }">
|
||||||
|
{{ value.length }}
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="footer">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.preview {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popselect {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
263
src/components/editor/MeEditorEmoticon.vue
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useEditorStore } from '@/store'
|
||||||
|
import { UploadOne, Delete } from '@icon-park/vue-next'
|
||||||
|
import { emojis } from '@/utils/emojis'
|
||||||
|
|
||||||
|
const emit = defineEmits(['on-select'])
|
||||||
|
const editorStore = useEditorStore()
|
||||||
|
const fileImageRef = ref()
|
||||||
|
const tabIndex = ref(0)
|
||||||
|
const items = computed<any[]>(() => editorStore.emoticon.items)
|
||||||
|
|
||||||
|
// 触发上传按钮事件
|
||||||
|
const onTriggerUpload = () => {
|
||||||
|
fileImageRef.value.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传表情包
|
||||||
|
const onUpload = (e: any) => {
|
||||||
|
let file = e.target.files[0]
|
||||||
|
|
||||||
|
editorStore.uploadUserEmoticon(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除表情包
|
||||||
|
const onDelete = (index: number, id: number) => {
|
||||||
|
editorStore.removeUserEmoticon({ index, id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTabs = (index: number) => {
|
||||||
|
tabIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSendEmoticon = (type: any, value: any, img = '') => {
|
||||||
|
if (img) {
|
||||||
|
const imgSrcReg = /<img.*?src='(.*?)'/g
|
||||||
|
let match = imgSrcReg.exec(img)
|
||||||
|
if (match) {
|
||||||
|
emit('on-select', { type, value, img: match[1] })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emit('on-select', { type, value, img })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<form enctype="multipart/form-data" style="display: none">
|
||||||
|
<input type="file" ref="fileImageRef" accept="image/*" @change="onUpload" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="el-container is-vertical section height100">
|
||||||
|
<header class="el-header em-header bdr-b">
|
||||||
|
<span>{{ items[tabIndex].name }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="el-main em-main me-scrollbar me-scrollbar-thumb">
|
||||||
|
<div class="symbol-box" v-if="tabIndex == 0">
|
||||||
|
<div class="options">
|
||||||
|
<div
|
||||||
|
v-for="(img, key) in emojis"
|
||||||
|
v-html="img"
|
||||||
|
:key="key"
|
||||||
|
@click="onSendEmoticon(1, key, img)"
|
||||||
|
class="option pointer flex-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collect-box" v-else>
|
||||||
|
<div v-if="tabIndex == 1" class="item pointer upload-btn" @click="onTriggerUpload">
|
||||||
|
<n-icon size="28" class="icon" :component="UploadOne" />
|
||||||
|
<span>自定义</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item pointer" v-for="(item, index) in items[tabIndex].children" :key="index">
|
||||||
|
<img :src="item.src" @click="onSendEmoticon(2, item.media_id)" />
|
||||||
|
|
||||||
|
<div v-if="tabIndex == 1" class="mask" @click="onDelete(index, item.media_id)">
|
||||||
|
<n-icon size="18" color="#ff5722" class="icon" :component="Delete" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="el-footer em-footer tabs">
|
||||||
|
<div
|
||||||
|
class="tab pointer"
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
@click="onTabs(index)"
|
||||||
|
:class="{ active: index == tabIndex }"
|
||||||
|
>
|
||||||
|
<p class="tip">{{ item.name }}</p>
|
||||||
|
<img width="20" height="20" :src="item.icon" />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.section {
|
||||||
|
width: 500px;
|
||||||
|
height: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--im-bg-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
.em-header {
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 10px;
|
||||||
|
|
||||||
|
.sys-btn {
|
||||||
|
color: #409eff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-main {
|
||||||
|
height: 100px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-footer {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
.tab {
|
||||||
|
position: relative;
|
||||||
|
height: 26px;
|
||||||
|
width: 26px;
|
||||||
|
margin: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--im-active-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: -32px;
|
||||||
|
height: 26px;
|
||||||
|
min-width: 20px;
|
||||||
|
white-space: pre;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: var(--im-active-bg-color);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--im-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.tip {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
background-color: var(--im-active-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol-box {
|
||||||
|
.title {
|
||||||
|
width: 50%;
|
||||||
|
height: 25px;
|
||||||
|
line-height: 25px;
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 400;
|
||||||
|
padding-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.options {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.option {
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
margin: 2px;
|
||||||
|
font-size: 24px;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.5s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.collect-box {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 5px;
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #858585;
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
position: relative;
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
background-color: #eff1f7;
|
||||||
|
margin: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.mask {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.collect-box .item {
|
||||||
|
background-color: #2c2c32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
96
src/components/editor/MeEditorImage.vue
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, onMounted } from 'vue'
|
||||||
|
import { NModal } from 'naive-ui'
|
||||||
|
import { fileFormatSize } from '@/utils/strings'
|
||||||
|
import { emitCall } from '@/utils/common'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'submit'])
|
||||||
|
const props = defineProps({
|
||||||
|
file: {
|
||||||
|
type: File,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const state: any = reactive({
|
||||||
|
show: true,
|
||||||
|
src: '',
|
||||||
|
size: '',
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSendClick = () => {
|
||||||
|
state.loading = true
|
||||||
|
|
||||||
|
let call = emitCall(null, null, (value) => {
|
||||||
|
state.loading = false
|
||||||
|
value && onMaskClick()
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('submit', call)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFileSrc(file) {
|
||||||
|
let reader = new FileReader()
|
||||||
|
|
||||||
|
state.size = file.size
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
state.src = reader.result
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFileSrc(props.file)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="state.show"
|
||||||
|
class="custom-card"
|
||||||
|
preset="card"
|
||||||
|
title="图片预览"
|
||||||
|
size="huge"
|
||||||
|
:bordered="false"
|
||||||
|
style="max-width: 455px; border-radius: 10px"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
>
|
||||||
|
<div class="preview">
|
||||||
|
<img :src="state.src" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div style="width: 100%; text-align: center">
|
||||||
|
<n-button type="primary" @click="onSendClick" :loading="state.loading">
|
||||||
|
发送图片({{ fileFormatSize(state.size) }})
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.preview {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC);
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
0
src/components/editor/MeEditorLocation.vue
Normal file
331
src/components/editor/MeEditorRecorder.vue
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import { NModal } from 'naive-ui'
|
||||||
|
import { Voice } from '@icon-park/vue-next'
|
||||||
|
import Recorder from 'js-audio-recorder'
|
||||||
|
import { countDownTime } from '@/utils/functions'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'on-submit'])
|
||||||
|
|
||||||
|
const isShow = ref(true)
|
||||||
|
const status = ref(0) // 0 未开始 1录制中 2已结束
|
||||||
|
const animation = ref(false)
|
||||||
|
const duration = ref(0)
|
||||||
|
let recorder: any = null
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
onDestroy()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDestroy = () => {
|
||||||
|
if (recorder) {
|
||||||
|
recorder.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
let blob = recorder.getWAVBlob()
|
||||||
|
|
||||||
|
let file = new File([blob], '在线录音.wav', {
|
||||||
|
type: blob.type,
|
||||||
|
lastModified: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('on-submit', file)
|
||||||
|
onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStart = () => {
|
||||||
|
recorder = new Recorder()
|
||||||
|
|
||||||
|
recorder.start().then(
|
||||||
|
() => {
|
||||||
|
animation.value = true
|
||||||
|
status.value = 1
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log(`${error.name} : ${error.message}`)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.onprocess = (value) => {
|
||||||
|
duration.value = parseInt(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStop = () => {
|
||||||
|
recorder.stop()
|
||||||
|
|
||||||
|
animation.value = false
|
||||||
|
status.value = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
onDestroy()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShow"
|
||||||
|
preset="card"
|
||||||
|
title="语音录制"
|
||||||
|
class="modal-radius"
|
||||||
|
style="max-width: 450px"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
:mask-closable="false"
|
||||||
|
>
|
||||||
|
<main class="main-box">
|
||||||
|
<div class="music">
|
||||||
|
<span class="line line1" :class="{ 'line-ani': animation }"></span>
|
||||||
|
<span class="line line2" :class="{ 'line-ani': animation }"></span>
|
||||||
|
<span class="line line3" :class="{ 'line-ani': animation }"></span>
|
||||||
|
<span class="line line4" :class="{ 'line-ani': animation }"></span>
|
||||||
|
<span class="line line5" :class="{ 'line-ani': animation }"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tip">
|
||||||
|
<p>
|
||||||
|
<span v-show="status">{{ status == 1 ? '正在录音' : '已暂停录音' }}</span>
|
||||||
|
{{ countDownTime(duration) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="footer">
|
||||||
|
<n-button v-show="status == 0" type="primary" ghost round @click="onStart">
|
||||||
|
<n-icon :component="Voice" />
|
||||||
|
开始录音
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button v-show="status == 1" type="primary" round @click="onStop">
|
||||||
|
<n-icon :component="Voice" />
|
||||||
|
结束录音
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button v-show="status == 2" type="primary" ghost round @click="onStart">
|
||||||
|
重新录音
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button v-show="status == 2" type="primary" round @click="onSubmit"> 发送录音 </n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.main-box {
|
||||||
|
height: 300px;
|
||||||
|
width: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
margin-top: 35px;
|
||||||
|
color: rgb(103, 98, 98);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
:deep(.n-button) {
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
height: 160px;
|
||||||
|
border: 8px solid #eae8e8;
|
||||||
|
border-bottom: 0px;
|
||||||
|
border-top-left-radius: 110px;
|
||||||
|
border-top-right-radius: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music:before,
|
||||||
|
.music:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -20px;
|
||||||
|
width: 40px;
|
||||||
|
height: 82px;
|
||||||
|
background-color: #eae8e8;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music:before {
|
||||||
|
right: -25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music:after {
|
||||||
|
left: -25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
position: absolute;
|
||||||
|
width: 6px;
|
||||||
|
min-height: 30px;
|
||||||
|
transition: 0.5s;
|
||||||
|
|
||||||
|
vertical-align: middle;
|
||||||
|
bottom: 0 !important;
|
||||||
|
box-shadow: inset 0px 0px 16px -2px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-ani {
|
||||||
|
animation: equalize 4s 0s infinite;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line1 {
|
||||||
|
left: 30%;
|
||||||
|
bottom: 0px;
|
||||||
|
animation-delay: -1.9s;
|
||||||
|
background-color: #ff5e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line2 {
|
||||||
|
left: 40%;
|
||||||
|
height: 60px;
|
||||||
|
bottom: -15px;
|
||||||
|
animation-delay: -2.9s;
|
||||||
|
background-color: #a64de6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line3 {
|
||||||
|
left: 50%;
|
||||||
|
height: 30px;
|
||||||
|
bottom: -1.5px;
|
||||||
|
animation-delay: -3.9s;
|
||||||
|
background-color: #5968dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line4 {
|
||||||
|
left: 60%;
|
||||||
|
height: 65px;
|
||||||
|
bottom: -16px;
|
||||||
|
animation-delay: -4.9s;
|
||||||
|
background-color: #27c8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line5 {
|
||||||
|
left: 70%;
|
||||||
|
height: 60px;
|
||||||
|
bottom: -12px;
|
||||||
|
animation-delay: -5.9s;
|
||||||
|
background-color: #cc60b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes equalize {
|
||||||
|
0% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
4% {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
8% {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
12% {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
16% {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
20% {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
24% {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
28% {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
32% {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
36% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
44% {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
48% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
52% {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
56% {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
64% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
68% {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
72% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
76% {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
84% {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
88% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
92% {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
96% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
130
src/components/editor/MeEditorVote.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, computed, ref } from 'vue'
|
||||||
|
import { NModal, NForm, NFormItem, NInput, NRadioGroup, NSpace, NRadio } from 'naive-ui'
|
||||||
|
import { Delete } from '@icon-park/vue-next'
|
||||||
|
const emit = defineEmits(['close', 'submit'])
|
||||||
|
|
||||||
|
const isShow = ref(true)
|
||||||
|
const model = reactive({
|
||||||
|
mode: 0,
|
||||||
|
anonymous: 0,
|
||||||
|
title: '',
|
||||||
|
options: [{ value: '' }, { value: '' }, { value: '' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
let data = {
|
||||||
|
title: model.title,
|
||||||
|
mode: model.mode,
|
||||||
|
anonymous: model.anonymous,
|
||||||
|
options: model.options.map((item) => item.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
model.options.push({ value: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const delOption = (index: number) => {
|
||||||
|
model.options.length > 2 && model.options.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否可提交
|
||||||
|
const isCanSubmit = computed(() => {
|
||||||
|
return (
|
||||||
|
model.title.trim().length == 0 || model.options.some((item) => item.value.trim().length === 0)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShow"
|
||||||
|
preset="card"
|
||||||
|
title="发起投票"
|
||||||
|
class="modal-radius"
|
||||||
|
:style="{ maxWidth: '450px' }"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="投票方式" :required="true">
|
||||||
|
<n-radio-group v-model:value="model.anonymous">
|
||||||
|
<n-space>
|
||||||
|
<n-radio :value="0"> 公开投票 </n-radio>
|
||||||
|
<n-radio :value="1"> 匿名投票 </n-radio>
|
||||||
|
</n-space>
|
||||||
|
</n-radio-group>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="选择方式" :required="true">
|
||||||
|
<n-radio-group v-model:value="model.mode">
|
||||||
|
<n-space>
|
||||||
|
<n-radio :value="0"> 单选 </n-radio>
|
||||||
|
<n-radio :value="1"> 多选 </n-radio>
|
||||||
|
</n-space>
|
||||||
|
</n-radio-group>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="投票主题" :required="true">
|
||||||
|
<n-input placeholder="请输入投票主题,最多50字" v-model:value="model.title" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="投票选项" :required="true">
|
||||||
|
<div class="options">
|
||||||
|
<div v-for="(option, i) in model.options" :key="i" class="option">
|
||||||
|
<n-input placeholder=" 请输入选项内容" v-model:value="option.value">
|
||||||
|
<template #prefix>
|
||||||
|
<span style="color: #ccc"> {{ String.fromCharCode(65 + i) }}. </span>
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
|
||||||
|
<div class="btn flex-center pointer" @click="delOption(i)">
|
||||||
|
<n-icon size="16" :component="Delete" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-button text type="primary" @click="addOption" v-if="model.options.length < 6">
|
||||||
|
添加选项
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div style="width: 100%; text-align: right">
|
||||||
|
<n-button type="tertiary" @click="isShow = false"> 取消 </n-button>
|
||||||
|
<n-button type="primary" @click="onSubmit" class="mt-l15" :disabled="isCanSubmit">
|
||||||
|
发起投票
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.options {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.option {
|
||||||
|
margin: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
40
src/components/editor/formats/emoji.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import Quill from 'quill'
|
||||||
|
|
||||||
|
const ImageBlot = Quill.import('formats/image')
|
||||||
|
|
||||||
|
class EmojiBlot extends ImageBlot {
|
||||||
|
static blotName = 'emoji'
|
||||||
|
static tagName = 'img'
|
||||||
|
static className = 'ed-emoji'
|
||||||
|
|
||||||
|
static create(value: HTMLImageElement) {
|
||||||
|
const node = super.create()
|
||||||
|
|
||||||
|
node.setAttribute('alt', value.alt)
|
||||||
|
node.setAttribute('src', value.src)
|
||||||
|
node.setAttribute('width', value.width)
|
||||||
|
node.setAttribute('height', value.height)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
static formats(node: HTMLImageElement) {
|
||||||
|
return {
|
||||||
|
alt: node.getAttribute('alt'),
|
||||||
|
src: node.getAttribute('src'),
|
||||||
|
width: node.getAttribute('width'),
|
||||||
|
height: node.getAttribute('height')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static value(node: HTMLImageElement) {
|
||||||
|
// 主要在有初始值时起作用
|
||||||
|
return {
|
||||||
|
alt: node.getAttribute('alt'),
|
||||||
|
src: node.getAttribute('src'),
|
||||||
|
width: node.getAttribute('width'),
|
||||||
|
height: node.getAttribute('height')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmojiBlot
|
70
src/components/editor/formats/quote.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import Quill from 'quill'
|
||||||
|
|
||||||
|
const BlockEmbed = Quill.import('blots/block/embed')
|
||||||
|
|
||||||
|
class QuoteBlot extends BlockEmbed {
|
||||||
|
static blotName = 'quote'
|
||||||
|
static tagName = 'div'
|
||||||
|
static className = 'quote-card'
|
||||||
|
|
||||||
|
static create(value: any): any {
|
||||||
|
const node = super.create(value)
|
||||||
|
|
||||||
|
const { id, title, describe, image } = value
|
||||||
|
|
||||||
|
node.dataset.id = id
|
||||||
|
node.dataset.title = title
|
||||||
|
node.dataset.describe = describe
|
||||||
|
node.dataset.image = image
|
||||||
|
|
||||||
|
node.setAttribute('contenteditable', 'false')
|
||||||
|
|
||||||
|
const quoteCardContent = document.createElement('span')
|
||||||
|
quoteCardContent.classList.add('quote-card-content')
|
||||||
|
|
||||||
|
const close = document.createElement('span')
|
||||||
|
close.classList.add('quote-card-remove')
|
||||||
|
close.textContent = '×'
|
||||||
|
close.addEventListener('click', () => {
|
||||||
|
node.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
const quoteCardTitle = document.createElement('span')
|
||||||
|
quoteCardTitle.classList.add('quote-card-title')
|
||||||
|
quoteCardTitle.textContent = title
|
||||||
|
quoteCardTitle.appendChild(close)
|
||||||
|
|
||||||
|
quoteCardContent.appendChild(quoteCardTitle)
|
||||||
|
|
||||||
|
if (image.length == 0) {
|
||||||
|
const quoteCardMeta = document.createElement('span')
|
||||||
|
quoteCardMeta.classList.add('quote-card-meta')
|
||||||
|
quoteCardMeta.textContent = describe
|
||||||
|
quoteCardContent.appendChild(quoteCardMeta)
|
||||||
|
} else {
|
||||||
|
const iconImg = document.createElement('img')
|
||||||
|
iconImg.setAttribute('src', image)
|
||||||
|
iconImg.setAttribute('style', 'width:30px;height:30px;margin-right:10px;')
|
||||||
|
quoteCardContent.appendChild(iconImg)
|
||||||
|
}
|
||||||
|
|
||||||
|
node.ondblclick = () => {
|
||||||
|
console.log('quote card ondblclick')
|
||||||
|
}
|
||||||
|
|
||||||
|
node.appendChild(quoteCardContent)
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
static value(node: HTMLElement): any {
|
||||||
|
return {
|
||||||
|
id: node.dataset.id,
|
||||||
|
title: node.dataset.title,
|
||||||
|
describe: node.dataset.describe,
|
||||||
|
image: node.dataset.image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuoteBlot
|
175
src/components/editor/util.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { Delta } from '@vueup/vue-quill'
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
type: number
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalysisResp {
|
||||||
|
items: Item[]
|
||||||
|
mentions: any[]
|
||||||
|
mentionUids: number[]
|
||||||
|
msgType: number // 1 文本;2:图片;3图文混合消息
|
||||||
|
quoteId: string // 引用的消息ID
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLeadingNewlines(str: string) {
|
||||||
|
return str.replace(/^[\n\s]+/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTrailingNewlines(str: string) {
|
||||||
|
return str.replace(/[\n\s]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deltaToMessage(delta: Delta): AnalysisResp {
|
||||||
|
const resp: AnalysisResp = {
|
||||||
|
items: [],
|
||||||
|
mentions: [],
|
||||||
|
mentionUids: [],
|
||||||
|
quoteId: '',
|
||||||
|
msgType: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const iterator of delta.ops) {
|
||||||
|
const insert: any = iterator.insert
|
||||||
|
|
||||||
|
let node: any = null
|
||||||
|
if (resp.items.length) {
|
||||||
|
node = resp.items[resp.items.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof insert === 'string') {
|
||||||
|
if (!insert || insert == '\n') continue
|
||||||
|
|
||||||
|
if (node && node.type == 1) {
|
||||||
|
node.content = node.content + insert
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.items.push({
|
||||||
|
type: 1,
|
||||||
|
content: insert
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// @好友
|
||||||
|
if (insert && insert.mention) {
|
||||||
|
const mention = insert.mention
|
||||||
|
|
||||||
|
resp.mentions.push({
|
||||||
|
name: `${mention.denotationChar}${mention.value}`,
|
||||||
|
atid: parseInt(mention.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (node && node.type == 1) {
|
||||||
|
node.content = node.content + ` ${mention.denotationChar}${mention.value}`
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.items.push({
|
||||||
|
type: 1,
|
||||||
|
content: `${mention.denotationChar}${mention.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片
|
||||||
|
if (insert && insert.image) {
|
||||||
|
resp.items.push({
|
||||||
|
type: 3,
|
||||||
|
content: insert.image
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表情
|
||||||
|
if (insert && insert.emoji) {
|
||||||
|
const { emoji } = insert
|
||||||
|
|
||||||
|
if (node && node.type == 1) {
|
||||||
|
node.content = node.content + emoji.alt
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.items.push({
|
||||||
|
type: 1,
|
||||||
|
content: emoji.alt
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insert && insert.quote) {
|
||||||
|
resp.quoteId = insert.quote.id
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去除前后多余空格
|
||||||
|
if (resp.items.length) {
|
||||||
|
if (resp.items[0].type == 1) {
|
||||||
|
resp.items[0].content = removeLeadingNewlines(resp.items[0].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.items[resp.items.length - 1].type == 1) {
|
||||||
|
resp.items[resp.items.length - 1].content = removeTrailingNewlines(
|
||||||
|
resp.items[resp.items.length - 1].content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.items.length > 1) {
|
||||||
|
resp.msgType = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.items.length == 1) {
|
||||||
|
resp.msgType = resp.items[0].type
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.mentionUids = resp.mentions.map((item) => item.atid)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deltaToString(delta: Delta): string {
|
||||||
|
let content = ''
|
||||||
|
|
||||||
|
for (const o of delta.ops) {
|
||||||
|
const insert: any = o.insert
|
||||||
|
|
||||||
|
if (typeof insert === 'string') {
|
||||||
|
if (!insert || insert == '\n') continue
|
||||||
|
|
||||||
|
content += insert
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// @好友
|
||||||
|
if (insert && insert.mention) {
|
||||||
|
const { mention } = insert
|
||||||
|
content += ` ${mention.denotationChar}${mention.value} `
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片
|
||||||
|
if (insert && insert.image) {
|
||||||
|
content += '[图片]'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表情
|
||||||
|
if (insert && insert.emoji) {
|
||||||
|
content += insert.emoji.alt
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmptyDelta(delta: Delta): boolean {
|
||||||
|
return delta.ops.length == 1 && delta.ops[0].insert == '\n'
|
||||||
|
}
|
78
src/components/group/GroupApply.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { NModal, NForm, NFormItem, NInput } from 'naive-ui'
|
||||||
|
import { ServeCreateGroupApply } from '@/api/group'
|
||||||
|
|
||||||
|
const remark = ref('')
|
||||||
|
const props = defineProps({
|
||||||
|
gid: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const isShow = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
let resp = ServeCreateGroupApply({
|
||||||
|
group_id: props.gid,
|
||||||
|
remark: remark.value
|
||||||
|
})
|
||||||
|
|
||||||
|
resp.then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success('入群申请提交成功...')
|
||||||
|
onMaskClick()
|
||||||
|
} else {
|
||||||
|
window['$message'].warning(res.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resp.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShow"
|
||||||
|
preset="card"
|
||||||
|
title="入群申请"
|
||||||
|
class="modal-radius"
|
||||||
|
style="max-width: 450px"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="申请备注" required>
|
||||||
|
<n-input placeholder="请填写申请备注" type="textarea" v-model:value="remark" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div style="width: 100%; text-align: right">
|
||||||
|
<n-button type="tertiary" @click="onMaskClick"> 取消 </n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
class="mt-l15"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!remark"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped></style>
|
311
src/components/group/GroupLaunch.vue
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { NModal, NInput, NScrollbar, NDivider, NCheckbox, NForm, NFormItem } from 'naive-ui'
|
||||||
|
import { Search, Delete } from '@icon-park/vue-next'
|
||||||
|
import { ServeCreateGroup, ServeInviteGroup, ServeGetInviteFriends } from '@/api/group'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'on-submit', 'on-invite'])
|
||||||
|
const props = defineProps({
|
||||||
|
gid: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const model = reactive({
|
||||||
|
keywords: '',
|
||||||
|
name: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const isShowBox = ref(true)
|
||||||
|
|
||||||
|
const searchFilter = computed(() => {
|
||||||
|
if (model.keywords) {
|
||||||
|
return items.value.filter((item) => {
|
||||||
|
return item.nickname.match(model.keywords) != null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkedFilter = computed(() => {
|
||||||
|
return items.value.filter((item) => item.checked)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCanSubmit = computed(() => {
|
||||||
|
if (props.gid > 0) {
|
||||||
|
return !checkedFilter.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(model.name.trim() && checkedFilter.value.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
model.name = ''
|
||||||
|
items.value.forEach((item) => {
|
||||||
|
item.checked = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoad = () => {
|
||||||
|
ServeGetInviteFriends({
|
||||||
|
group_id: props.gid
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.code == 200 && res.data) {
|
||||||
|
let list = res.data || []
|
||||||
|
items.value = list.map((item: any) => {
|
||||||
|
return Object.assign(item, {
|
||||||
|
nickname: item.friend_remark ? item.friend_remark : item.nickname,
|
||||||
|
checked: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTriggerContact = (item) => {
|
||||||
|
let data = items.value.find((val) => {
|
||||||
|
return val.id === item.id
|
||||||
|
})
|
||||||
|
|
||||||
|
data && (data.checked = !data.checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCreateSubmit = (ids: number[]) => {
|
||||||
|
ServeCreateGroup({
|
||||||
|
avatar: '',
|
||||||
|
name: model.name,
|
||||||
|
profile: '',
|
||||||
|
ids: ids.join(',')
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
onReset()
|
||||||
|
emit('on-submit', res.data)
|
||||||
|
window['$message'].success('创建成功')
|
||||||
|
isShowBox.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInviteSubmit = (ids: number[]) => {
|
||||||
|
ServeInviteGroup({
|
||||||
|
group_id: props.gid,
|
||||||
|
ids: ids.join(',')
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
emit('on-invite')
|
||||||
|
window['$message'].success('邀请成功')
|
||||||
|
isShowBox.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
const ids = checkedFilter.value.map((item) => item.id)
|
||||||
|
|
||||||
|
if (props.gid == 0) {
|
||||||
|
onCreateSubmit(ids)
|
||||||
|
} else {
|
||||||
|
onInviteSubmit(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShowBox"
|
||||||
|
preset="card"
|
||||||
|
:title="gid == 0 ? '创建群聊' : '邀请新的联系人'"
|
||||||
|
class="modal-radius"
|
||||||
|
style="max-width: 650px; height: 550px"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
:segmented="{
|
||||||
|
content: true,
|
||||||
|
footer: true
|
||||||
|
}"
|
||||||
|
:content-style="{
|
||||||
|
padding: 0
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<section class="el-container launch-box">
|
||||||
|
<aside class="el-aside bdr-r" style="width: 280px" v-loading="loading">
|
||||||
|
<section class="el-container is-vertical height100">
|
||||||
|
<header class="el-header" style="height: 50px; padding: 16px">
|
||||||
|
<n-input placeholder="搜索" v-model:value="model.keywords" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<n-icon :component="Search" />
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
</header>
|
||||||
|
<main class="el-main o-hidden">
|
||||||
|
<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.nickname"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<span class="text-ellipsis">{{ item.nickname }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox">
|
||||||
|
<n-checkbox
|
||||||
|
size="small"
|
||||||
|
:checked="item.checked"
|
||||||
|
@update:checked="item.checked = !item.checked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-scrollbar>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="el-main">
|
||||||
|
<section class="el-container is-vertical height100">
|
||||||
|
<header v-if="props.gid === 0" class="el-header" style="height: 90px; padding: 10px 15px">
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="群聊名称" :required="true">
|
||||||
|
<n-input v-model:value="model.name" placeholder="必填" maxlength="20" show-count />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<header class="el-header" style="height: 50px">
|
||||||
|
<n-divider
|
||||||
|
title-placement="left"
|
||||||
|
style="margin-top: 15px; margin-bottom: 0; font-weight: 300"
|
||||||
|
>
|
||||||
|
邀请成员({{ checkedFilter.length }})
|
||||||
|
</n-divider>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="el-main o-hidden">
|
||||||
|
<n-scrollbar>
|
||||||
|
<div class="friend-items">
|
||||||
|
<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.nickname"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<span class="text-ellipsis">{{ item.nickname }}</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">
|
||||||
|
<n-button type="tertiary" @click="isShowBox = false"> 取消 </n-button>
|
||||||
|
<n-button type="primary" class="mt-l15" @click="onSubmit" :disabled="isCanSubmit">
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
:deep(.n-divider__title) {
|
||||||
|
font-weight: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-box {
|
||||||
|
height: 410px;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.friend-items {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 6px;
|
||||||
|
|
||||||
|
.friend-item {
|
||||||
|
height: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin: 5px 10px;
|
||||||
|
|
||||||
|
> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
186
src/components/group/GroupNotice.vue
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Up, Down, Close } from '@icon-park/vue-next'
|
||||||
|
import Loading from '@/components/base/Loading.vue'
|
||||||
|
import { ServeGetGroupNotices } from '@/api/group'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const props = defineProps({
|
||||||
|
groupId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = ref('群公告')
|
||||||
|
const loading = ref(false)
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadData = () => {
|
||||||
|
loading.value = true
|
||||||
|
ServeGetGroupNotices({
|
||||||
|
group_id: props.groupId
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
let list = res.data.items || []
|
||||||
|
|
||||||
|
list.forEach((item: any) => {
|
||||||
|
item.is_show = false
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = list
|
||||||
|
title.value = `群公告(${items.value.length})`
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onLoadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="el-container is-vertical">
|
||||||
|
<header class="el-header bdr-b">
|
||||||
|
<div class="center-text">
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-icon">
|
||||||
|
<n-icon size="21" :component="Close" @click="onClose" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="el-main me-scrollbar me-scrollbar-thumb">
|
||||||
|
<div v-if="loading" class="flex-box flex-center">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="items.length === 0" class="flex-box flex-center">
|
||||||
|
<n-empty size="200" description="暂无相关数据">
|
||||||
|
<template #icon>
|
||||||
|
<img src="@/assets/image/no-data.svg" alt="" />
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="item in items" :key="item.id" class="items">
|
||||||
|
<div class="title text-ellipsis">
|
||||||
|
{{ item.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="describe">
|
||||||
|
<n-avatar round :size="15" :src="item.avatar" />
|
||||||
|
<span class="nickname text-ellipsis">{{ item.nickname }}</span>
|
||||||
|
<span class="datetime">发表于 {{ item.created_at }}</span>
|
||||||
|
<span class="btn" @click="item.is_show = !item.is_show">
|
||||||
|
<n-icon :size="18" :component="item.is_show ? Up : Down" />
|
||||||
|
{{ item.is_show ? '收起' : '展开' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail" v-show="item.is_show">
|
||||||
|
{{ item.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.el-header {
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 15px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-text {
|
||||||
|
flex: auto;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-icon,
|
||||||
|
.right-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-main {
|
||||||
|
padding: 0 15px;
|
||||||
|
|
||||||
|
.flex-box {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 10px 0;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.describe {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
margin-top: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.datetime {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a59696;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
min-height: 30px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 15px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #887f7f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
484
src/components/group/GroupPanel.vue
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, computed, watch, ref } from 'vue'
|
||||||
|
import { NEmpty, NPopover, NPopconfirm } from 'naive-ui'
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
import GroupLaunch from './GroupLaunch.vue'
|
||||||
|
import GroupManage from './manage/index.vue'
|
||||||
|
import { Comment, Search, Close, Plus } from '@icon-park/vue-next'
|
||||||
|
import {
|
||||||
|
ServeGroupDetail,
|
||||||
|
ServeGetGroupMembers,
|
||||||
|
ServeSecedeGroup,
|
||||||
|
ServeUpdateGroupCard
|
||||||
|
} from '@/api/group'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'to-talk'])
|
||||||
|
const props = defineProps({
|
||||||
|
gid: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(props, () => {
|
||||||
|
loadDetail()
|
||||||
|
loadMembers()
|
||||||
|
})
|
||||||
|
|
||||||
|
const editCardPopover = ref(false)
|
||||||
|
const isShowGroup = ref(false)
|
||||||
|
const isShowManage = ref(false)
|
||||||
|
const state = reactive({
|
||||||
|
keywords: '',
|
||||||
|
detail: {
|
||||||
|
avatar: '',
|
||||||
|
name: '',
|
||||||
|
profile: '',
|
||||||
|
visit_card: '',
|
||||||
|
notice: ''
|
||||||
|
},
|
||||||
|
remark: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const members = ref<any[]>([])
|
||||||
|
|
||||||
|
const search = computed<any[]>(() => {
|
||||||
|
if (state.keywords) {
|
||||||
|
return members.value.filter((item: any) => {
|
||||||
|
return (
|
||||||
|
item.nickname.match(state.keywords) != null || item.remark.match(state.keywords) != null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return members.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLeader = computed(() => {
|
||||||
|
return members.value.some((item: any) => {
|
||||||
|
return item.user_id == userStore.uid && item.leader >= 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAdmin = computed(() => {
|
||||||
|
return members.value.some((item: any) => {
|
||||||
|
return item.user_id == userStore.uid && item.leader == 2
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onShowManage = (vallue: any) => {
|
||||||
|
isShowManage.value = vallue
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGroupCallBack = () => {}
|
||||||
|
|
||||||
|
const onToInfo = (item: any) => {
|
||||||
|
showUserInfoModal(item.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载群信息
|
||||||
|
*/
|
||||||
|
function loadDetail() {
|
||||||
|
ServeGroupDetail({
|
||||||
|
group_id: props.gid
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
let result = res.data
|
||||||
|
state.detail.avatar = result.avatar
|
||||||
|
state.detail.name = result.group_name
|
||||||
|
state.detail.profile = result.profile
|
||||||
|
state.detail.visit_card = result.visit_card
|
||||||
|
state.remark = result.visit_card
|
||||||
|
|
||||||
|
if (result.notice) {
|
||||||
|
state.detail.notice = result.notice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载成员列表
|
||||||
|
*/
|
||||||
|
function loadMembers() {
|
||||||
|
ServeGetGroupMembers({
|
||||||
|
group_id: props.gid
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
members.value = res.data.items || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSignOut = () => {
|
||||||
|
ServeSecedeGroup({
|
||||||
|
group_id: props.gid
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success('已退出群聊')
|
||||||
|
onClose()
|
||||||
|
} else {
|
||||||
|
window['$message'].error(res.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeRemark = () => {
|
||||||
|
ServeUpdateGroupCard({
|
||||||
|
group_id: props.gid,
|
||||||
|
visit_card: state.remark
|
||||||
|
}).then(({ code, message }) => {
|
||||||
|
if (code == 200) {
|
||||||
|
// @ts-ignore
|
||||||
|
editCardPopover.value.setShow(false)
|
||||||
|
state.detail.visit_card = state.remark
|
||||||
|
window['$message'].success('已更新群名片')
|
||||||
|
|
||||||
|
loadMembers()
|
||||||
|
} else {
|
||||||
|
window['$message'].error(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDetail()
|
||||||
|
loadMembers()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="el-container is-vertical section">
|
||||||
|
<header class="el-header header bdr-b">
|
||||||
|
<div class="left-icon" @click="emit('to-talk')">
|
||||||
|
<n-icon size="21" :component="Comment" />
|
||||||
|
</div>
|
||||||
|
<div class="center-text">
|
||||||
|
<span>群信息</span>
|
||||||
|
</div>
|
||||||
|
<div class="right-icon">
|
||||||
|
<n-icon size="21" :component="Close" @click="onClose" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="el-main main me-scrollbar me-scrollbar-thumb">
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="b-box">
|
||||||
|
<div class="block">
|
||||||
|
<div class="title">群名称:</div>
|
||||||
|
</div>
|
||||||
|
<div class="describe">{{ state.detail.name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="b-box">
|
||||||
|
<div class="block">
|
||||||
|
<div class="title">群名片:</div>
|
||||||
|
<div class="text">
|
||||||
|
<n-popover trigger="click" placement="left" ref="editCardPopover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button type="primary" text> 设置 </n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #header> 设置我的群名片 </template>
|
||||||
|
|
||||||
|
<div style="display: flex">
|
||||||
|
<n-input
|
||||||
|
type="text"
|
||||||
|
placeholder="设置我的群名片"
|
||||||
|
maxlength="10"
|
||||||
|
v-model:value="state.remark"
|
||||||
|
@keydown.enter="onChangeRemark"
|
||||||
|
/>
|
||||||
|
<n-button type="primary" class="mt-l5" @click="onChangeRemark"> 确定 </n-button>
|
||||||
|
</div>
|
||||||
|
</n-popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="describe">{{ state.detail.visit_card || '未设置' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="b-box">
|
||||||
|
<div class="block">
|
||||||
|
<div class="title">群成员:</div>
|
||||||
|
<div class="text">{{ members.length }}人</div>
|
||||||
|
</div>
|
||||||
|
<div class="describe">群主已开启“新成员入群可查看所有聊天记录</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="b-box">
|
||||||
|
<div class="block">
|
||||||
|
<div class="title">群简介:</div>
|
||||||
|
</div>
|
||||||
|
<div class="describe">
|
||||||
|
{{ state.detail.profile ? state.detail.profile : '暂无群简介' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="b-box">
|
||||||
|
<div class="block">
|
||||||
|
<div class="title">群公告:</div>
|
||||||
|
<div class="text">
|
||||||
|
<n-button type="primary" text> 更多 </n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="describe">暂无群公告</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="member-box">
|
||||||
|
<div class="flex">
|
||||||
|
<n-input placeholder="搜索" v-model:value="state.keywords" :clearable="true" round>
|
||||||
|
<template #prefix>
|
||||||
|
<n-icon :component="Search" />
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
|
||||||
|
<n-button @click="isShowGroup = true" circle class="mt-l15">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="Plus" color="rgb(165 165 170)" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table">
|
||||||
|
<div class="theader">
|
||||||
|
<div class="avatar"></div>
|
||||||
|
<div class="nickname">用户昵称</div>
|
||||||
|
<div class="card">群名片</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row pointer" v-for="item in search" :key="item.id" @click="onToInfo(item)">
|
||||||
|
<div class="avatar">
|
||||||
|
<im-avatar :size="20" :src="item.avatar" :username="item.nickname" />
|
||||||
|
</div>
|
||||||
|
<div class="nickname text-ellipsis">
|
||||||
|
<span>{{ item.nickname ? item.nickname : '-' }}</span>
|
||||||
|
<span class="badge master" v-show="item.leader === 2">群主</span>
|
||||||
|
<span class="badge leader" v-show="item.leader === 1">管理员</span>
|
||||||
|
</div>
|
||||||
|
<div class="card text-ellipsis grey">
|
||||||
|
{{ item.remark || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-t20 pd-t20" v-if="search.length == 0">
|
||||||
|
<n-empty size="200" description="暂无相关数据">
|
||||||
|
<template #icon>
|
||||||
|
<img src="@/assets/image/no-data.svg" alt="" />
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="el-footer footer bdr-t">
|
||||||
|
<template v-if="!isAdmin">
|
||||||
|
<n-popconfirm negative-text="取消" positive-text="确定" @positive-click="onSignOut">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button class="btn" type="error" ghost> 退出群聊 </n-button>
|
||||||
|
</template>
|
||||||
|
确定要退出群吗? 退出后不再接收此群消息!
|
||||||
|
</n-popconfirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-button
|
||||||
|
class="btn"
|
||||||
|
type="primary"
|
||||||
|
text-color="#ffffff"
|
||||||
|
v-if="isLeader"
|
||||||
|
@click="onShowManage(true)"
|
||||||
|
>
|
||||||
|
群聊管理
|
||||||
|
</n-button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<GroupLaunch
|
||||||
|
v-if="isShowGroup"
|
||||||
|
:gid="gid"
|
||||||
|
@close="isShowGroup = false"
|
||||||
|
@on-submit="onGroupCallBack"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GroupManage v-if="isShowManage" :gid="gid" @close="onShowManage(false)" />
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.section {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-text {
|
||||||
|
flex: auto;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-icon,
|
||||||
|
.right-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 15px;
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
.b-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
margin: 12px 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: 100%;
|
||||||
|
line-height: 30px;
|
||||||
|
flex: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
height: 100%;
|
||||||
|
line-height: 30px;
|
||||||
|
width: 30%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.describe {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b1b1b1;
|
||||||
|
font-weight: 300;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-box {
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 20px 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-top: 15px;
|
||||||
|
.theader {
|
||||||
|
height: 36px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
height: 30px;
|
||||||
|
margin: 3px 0;
|
||||||
|
&:hover {
|
||||||
|
.nickname {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theader,
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 30px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100px;
|
||||||
|
padding-right: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
&.grey {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
height: 60px;
|
||||||
|
padding: 15px;
|
||||||
|
.btn {
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
margin-left: 3px;
|
||||||
|
&.master {
|
||||||
|
color: #dc9b04 !important;
|
||||||
|
background-color: #faf1d1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.leader {
|
||||||
|
color: #3370ff;
|
||||||
|
background-color: #e1eaff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
279
src/components/group/manage/ApplyTab.vue
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, h, onMounted } from 'vue'
|
||||||
|
import { NSpace, NInput } from 'naive-ui'
|
||||||
|
import { Search, CheckSmall, Close, Redo } from '@icon-park/vue-next'
|
||||||
|
import { ServeGetGroupApplyList, ServeDeleteGroupApply, ServeAgreeGroupApply } from '@/api/group'
|
||||||
|
import { throttle } from '@/utils/common'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
group_id: number
|
||||||
|
avatar: string
|
||||||
|
nickname: string
|
||||||
|
remark: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const keywords = ref('')
|
||||||
|
const batchDelete = ref(false)
|
||||||
|
const items = ref<Item[]>([])
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
|
||||||
|
const filterSearch = computed(() => {
|
||||||
|
if (!keywords.value.length) {
|
||||||
|
return items.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.value.filter((item) => {
|
||||||
|
return item.nickname.match(keywords.value) != null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onLoadData = () => {
|
||||||
|
ServeGetGroupApplyList({
|
||||||
|
group_id: props.id
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
let data = res.data.items || []
|
||||||
|
items.value = data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUserInfo = (item: Item) => {
|
||||||
|
showUserInfoModal(item.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRowClick = (item: Item) => {
|
||||||
|
if (batchDelete.value == true) {
|
||||||
|
console.log(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAgree = throttle((item: Item) => {
|
||||||
|
let loading = window['$message'].loading('请稍等,正在处理')
|
||||||
|
|
||||||
|
ServeAgreeGroupApply({
|
||||||
|
apply_id: item.id
|
||||||
|
}).then((res) => {
|
||||||
|
loading.destroy()
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success('已同意')
|
||||||
|
} else {
|
||||||
|
window['$message'].info(res.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoadData()
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
const onDelete = (item: Item) => {
|
||||||
|
let remark = ''
|
||||||
|
let dialog = window['$dialog'].create({
|
||||||
|
title: '拒绝入群申请',
|
||||||
|
content: () => {
|
||||||
|
return h(NInput, {
|
||||||
|
defaultValue: '',
|
||||||
|
placeholder: '请填写拒绝原因',
|
||||||
|
style: { marginTop: '20px' },
|
||||||
|
onInput: (value) => (remark = value),
|
||||||
|
autofocus: true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
negativeText: '取消',
|
||||||
|
positiveText: '提交',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
if (!remark.length) return false
|
||||||
|
|
||||||
|
dialog.loading = true
|
||||||
|
|
||||||
|
ServeDeleteGroupApply({
|
||||||
|
apply_id: item.id,
|
||||||
|
remark: remark
|
||||||
|
}).then((res) => {
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success('已拒绝')
|
||||||
|
} else {
|
||||||
|
window['$message'].info(res.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onLoadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="section el-container is-vertical height100">
|
||||||
|
<header class="el-header header bdr-b">
|
||||||
|
<p>申请管理({{ filterSearch.length }})</p>
|
||||||
|
<div>
|
||||||
|
<n-space>
|
||||||
|
<n-input
|
||||||
|
placeholder="搜索"
|
||||||
|
v-model:value.trim="keywords"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<n-icon :component="Search" />
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
|
||||||
|
<n-button circle @click="onLoadData">
|
||||||
|
<template #icon> <n-icon :component="Redo" /> </template>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main v-if="filterSearch.length === 0" class="el-main main flex-center">
|
||||||
|
<n-empty size="200" description="暂无相关数据">
|
||||||
|
<template #icon>
|
||||||
|
<img src="@/assets/image/no-data.svg" alt="" />
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else class="el-main main me-scrollbar me-scrollbar-thumb">
|
||||||
|
<div
|
||||||
|
class="member-item"
|
||||||
|
v-for="item in filterSearch"
|
||||||
|
:key="item.id"
|
||||||
|
@click="onRowClick(item)"
|
||||||
|
>
|
||||||
|
<div class="avatar pointer" @click="onUserInfo(item)">
|
||||||
|
<im-avatar :size="40" :src="item.avatar" :username="item.nickname" />
|
||||||
|
</div>
|
||||||
|
<div class="content pointer o-hidden">
|
||||||
|
<div class="item-title">
|
||||||
|
<p class="nickname text-ellipsis">
|
||||||
|
<span>{{ item.nickname }}</span>
|
||||||
|
<span class="date mt-l15">{{ item.created_at }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="item-text text-ellipsis">备注: {{ item.remark }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool flex-center">
|
||||||
|
<n-space>
|
||||||
|
<n-button @click="onAgree(item)" strong secondary circle type="primary" size="small">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="CheckSmall" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button @click="onDelete(item)" strong secondary circle type="tertiary" size="small">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="Close" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.header {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 0 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
height: 58px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
box-sizing: content-box;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
height: 28px;
|
||||||
|
width: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-text {
|
||||||
|
width: inherit;
|
||||||
|
height: 20px;
|
||||||
|
color: rgb(255 255 255 / 52%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
width: 100px;
|
||||||
|
height: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
background-color: #fdf9f9;
|
||||||
|
border-bottom-right-radius: 15px;
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
131
src/components/group/manage/ConfigTab.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { NForm, NFormItem, NSwitch, NPopconfirm } from 'naive-ui'
|
||||||
|
import { ServeDismissGroup, ServeMuteGroup, ServeGroupDetail, ServeOvertGroup } from '@/api/group'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const detail = reactive({
|
||||||
|
is_mute: false,
|
||||||
|
mute_loading: false,
|
||||||
|
|
||||||
|
is_overt: false,
|
||||||
|
overt_loading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const onLoadData = async () => {
|
||||||
|
const { data, code } = await ServeGroupDetail({ group_id: props.id })
|
||||||
|
|
||||||
|
if (code === 200) {
|
||||||
|
detail.is_mute = data.is_mute === 1
|
||||||
|
detail.is_overt = data.is_overt === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDismiss = async () => {
|
||||||
|
const { code, message } = await ServeDismissGroup({ group_id: props.id })
|
||||||
|
|
||||||
|
if (code === 200) {
|
||||||
|
emit('close')
|
||||||
|
window['$message'].success('群聊已解散')
|
||||||
|
} else {
|
||||||
|
window['$message'].info(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMute = (value: boolean) => {
|
||||||
|
detail.mute_loading = true
|
||||||
|
|
||||||
|
ServeMuteGroup({
|
||||||
|
group_id: props.id,
|
||||||
|
mode: detail.is_mute ? 2 : 1
|
||||||
|
})
|
||||||
|
.then(({ code, message }) => {
|
||||||
|
if (code == 200) {
|
||||||
|
detail.is_mute = value
|
||||||
|
} else {
|
||||||
|
window['$message'].info(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
detail.mute_loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOvert = (value: boolean) => {
|
||||||
|
detail.overt_loading = true
|
||||||
|
|
||||||
|
ServeOvertGroup({
|
||||||
|
group_id: props.id,
|
||||||
|
mode: detail.is_overt ? 2 : 1
|
||||||
|
})
|
||||||
|
.then(({ code, message }) => {
|
||||||
|
if (code == 200) {
|
||||||
|
detail.is_overt = value
|
||||||
|
} else {
|
||||||
|
window['$message'].info(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
detail.overt_loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoadData()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="section el-container is-vertical height100">
|
||||||
|
<header class="el-header header bdr-b">
|
||||||
|
<p>设置管理</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="el-main main">
|
||||||
|
<n-form label-placement="left" label-width="auto" require-mark-placement="right-hanging">
|
||||||
|
<n-form-item label="解散群聊:">
|
||||||
|
<n-popconfirm negative-text="取消" positive-text="确定" @positive-click="onDismiss">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button type="primary" size="small" text> 点击解散 </n-button>
|
||||||
|
</template>
|
||||||
|
确定要解散群聊吗? 此操作是不可逆的!
|
||||||
|
</n-popconfirm>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="公开可见:" feedback="开启后可在公开群聊列表展示。">
|
||||||
|
<n-switch
|
||||||
|
:rubber-band="false"
|
||||||
|
:value="detail.is_overt"
|
||||||
|
:loading="detail.overt_loading"
|
||||||
|
@update:value="onOvert"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="全员禁言:" feedback="开启后除群主和管理员以外,其它成员禁止发言。">
|
||||||
|
<n-switch
|
||||||
|
:rubber-band="false"
|
||||||
|
:value="detail.is_mute"
|
||||||
|
:loading="detail.mute_loading"
|
||||||
|
@update:value="onMute"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.header {
|
||||||
|
height: 60px;
|
||||||
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
130
src/components/group/manage/DetailTab.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { NForm, NFormItem, NInput } from 'naive-ui'
|
||||||
|
import AvatarCropper from '@/components/base/AvatarCropper.vue'
|
||||||
|
import { ServeGroupDetail, ServeEditGroup } from '@/api/group'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cropper = ref(false)
|
||||||
|
|
||||||
|
const modelDetail = reactive({
|
||||||
|
name: '',
|
||||||
|
avatar: '',
|
||||||
|
profile: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const onUploadAvatar = (avatar) => {
|
||||||
|
cropper.value = false
|
||||||
|
modelDetail.avatar = avatar
|
||||||
|
|
||||||
|
onSubmitBaseInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadData = () => {
|
||||||
|
ServeGroupDetail({ group_id: props.id }).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
modelDetail.name = res.data.group_name
|
||||||
|
modelDetail.avatar = res.data.avatar
|
||||||
|
modelDetail.profile = res.data.profile
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmitBaseInfo() {
|
||||||
|
if (modelDetail.name.trim() == '') {
|
||||||
|
return window['$message'].info('群名称不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
ServeEditGroup({
|
||||||
|
group_id: props.id,
|
||||||
|
group_name: modelDetail.name,
|
||||||
|
avatar: modelDetail.avatar,
|
||||||
|
profile: modelDetail.profile
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success('群信息更新成功')
|
||||||
|
} else {
|
||||||
|
window['$message'].error(res.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onLoadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="section el-container is-vertical height100">
|
||||||
|
<header class="el-header header bdr-b">
|
||||||
|
<p>基础信息</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="el-main main">
|
||||||
|
<n-form
|
||||||
|
ref="formRef"
|
||||||
|
:style="{
|
||||||
|
minWinth: '350px',
|
||||||
|
maxWidth: '350px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<n-form-item label="群头像:" path="name">
|
||||||
|
<n-avatar v-if="modelDetail.avatar" :size="60" :src="modelDetail.avatar" />
|
||||||
|
<n-avatar
|
||||||
|
v-else
|
||||||
|
:size="60"
|
||||||
|
:style="{
|
||||||
|
color: 'white',
|
||||||
|
backgroundColor: '#508afe',
|
||||||
|
fontSize: '18px'
|
||||||
|
}"
|
||||||
|
>{{ modelDetail.name.substring(0, 1) }}</n-avatar
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
size="tiny"
|
||||||
|
style="margin-left: 20px"
|
||||||
|
dashed
|
||||||
|
@click="cropper = true"
|
||||||
|
>
|
||||||
|
上传头像
|
||||||
|
</n-button>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="群名称:" required path="name">
|
||||||
|
<n-input placeholder="必填" type="text" v-model:value="modelDetail.name" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="群简介:" path="profile">
|
||||||
|
<n-input placeholder="选填" type="textarea" v-model:value="modelDetail.profile" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="">
|
||||||
|
<n-button type="primary" @click="onSubmitBaseInfo"> 保存信息 </n-button>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 头像裁剪组件 -->
|
||||||
|
<AvatarCropper v-if="cropper" @close="cropper = false" @success="onUploadAvatar" />
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.header {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
518
src/components/group/manage/MemberTab.vue
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, reactive, nextTick, inject } from 'vue'
|
||||||
|
import { NSpace, NDropdown, NCheckbox } from 'naive-ui'
|
||||||
|
import { Search, Plus } from '@icon-park/vue-next'
|
||||||
|
import GroupLaunch from '../GroupLaunch.vue'
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
import { StateDropdown } from '@/types/global'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ServeGetGroupMembers,
|
||||||
|
ServeRemoveMembersGroup,
|
||||||
|
ServeGroupAssignAdmin,
|
||||||
|
ServeGroupHandover,
|
||||||
|
ServeGroupNoSpeak
|
||||||
|
} from '@/api/group'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
user_id: number
|
||||||
|
avatar: string
|
||||||
|
nickname: string
|
||||||
|
gender: number
|
||||||
|
remark: string
|
||||||
|
is_mute: number
|
||||||
|
leader: number
|
||||||
|
is_delete: boolean
|
||||||
|
motto?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const isGroupLaunch = ref(false)
|
||||||
|
const keywords = ref('')
|
||||||
|
const batchDelete = ref(false)
|
||||||
|
const items = ref<Item[]>([])
|
||||||
|
|
||||||
|
const filterCheck = computed(() => {
|
||||||
|
return items.value.filter((item: any) => item.is_delete)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterSearch = computed(() => {
|
||||||
|
if (!keywords.value.length) {
|
||||||
|
return items.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.value.filter((item: any) => {
|
||||||
|
return item.nickname.match(keywords.value) != null || item.remark.match(keywords.value) != null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAdmin = computed(() => {
|
||||||
|
return items.value.some((item: any) => {
|
||||||
|
return item.user_id == userStore.uid && item.leader == 2
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const dropdown = reactive<StateDropdown>({
|
||||||
|
options: [],
|
||||||
|
show: false,
|
||||||
|
dropdownX: 0,
|
||||||
|
dropdownY: 0,
|
||||||
|
item: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onLoadData = () => {
|
||||||
|
ServeGetGroupMembers({
|
||||||
|
group_id: props.id
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
let data = res.data.items || []
|
||||||
|
|
||||||
|
data.forEach((item: Item) => {
|
||||||
|
item.is_delete = false
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDelete = (item: Item) => {
|
||||||
|
let title = `删除 [${item.nickname}] 群成员?`
|
||||||
|
|
||||||
|
window['$dialog'].create({
|
||||||
|
title: '温馨提示',
|
||||||
|
content: title,
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
ServeRemoveMembersGroup({
|
||||||
|
group_id: props.id,
|
||||||
|
members_ids: `${item.user_id}`
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
onLoadData()
|
||||||
|
window['$message'].success('删除成功')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBatchDelete = () => {
|
||||||
|
if (!filterCheck.value.length) return
|
||||||
|
|
||||||
|
window['$dialog'].create({
|
||||||
|
title: '温馨提示',
|
||||||
|
content: `批量删除群成员?`,
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
ServeRemoveMembersGroup({
|
||||||
|
group_id: props.id,
|
||||||
|
members_ids: filterCheck.value.map((item: Item) => item.user_id).join(',')
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
batchDelete.value = false
|
||||||
|
onLoadData()
|
||||||
|
window['$message'].success('删除成功')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRowClick = (item: Item) => {
|
||||||
|
if (batchDelete.value == true) {
|
||||||
|
if (item.leader < 2) {
|
||||||
|
item.is_delete = !item.is_delete
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showUserInfoModal(item.user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancelDelete = () => {
|
||||||
|
items.value.forEach((item: Item) => {
|
||||||
|
item.is_delete = false
|
||||||
|
})
|
||||||
|
|
||||||
|
batchDelete.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUserInfo = (item: Item) => {
|
||||||
|
showUserInfoModal(item.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAssignAdmin = (item: Item) => {
|
||||||
|
let title =
|
||||||
|
item.leader == 0
|
||||||
|
? `确定要给 [${item.nickname}] 分配管理员权限吗?`
|
||||||
|
: `确定解除 [${item.nickname}] 管理员权限吗?`
|
||||||
|
|
||||||
|
window['$dialog'].create({
|
||||||
|
title: '温馨提示',
|
||||||
|
content: title,
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
ServeGroupAssignAdmin({
|
||||||
|
mode: item.leader == 0 ? 1 : 2,
|
||||||
|
group_id: props.id,
|
||||||
|
user_ids: item.user_id+''
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success('操作成功')
|
||||||
|
onLoadData()
|
||||||
|
} else {
|
||||||
|
window['$message'].error(res.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTransfer = (item: Item) => {
|
||||||
|
window['$dialog'].create({
|
||||||
|
title: '温馨提示',
|
||||||
|
content: `确定把群主权限转交给 [${item.nickname}] ?`,
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
ServeGroupHandover({
|
||||||
|
group_id: props.id,
|
||||||
|
user_id: item.user_id
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success('操作成功')
|
||||||
|
onLoadData()
|
||||||
|
} else {
|
||||||
|
window['$message'].error(res.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onForbidden = (item: Item) => {
|
||||||
|
let content = `确定要禁言 [${item.nickname}] 此用户吗?`
|
||||||
|
|
||||||
|
if (item.is_mute === 1) {
|
||||||
|
content = `确定要解除 [${item.nickname}] 此用户的禁言吗?`
|
||||||
|
}
|
||||||
|
|
||||||
|
window['$dialog'].create({
|
||||||
|
title: '温馨提示',
|
||||||
|
content: content,
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
ServeGroupNoSpeak({
|
||||||
|
mode: item.is_mute == 0 ? 1 : 2,
|
||||||
|
group_id: props.id,
|
||||||
|
user_ids: item.user_id+''
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success('操作成功')
|
||||||
|
onLoadData()
|
||||||
|
} else {
|
||||||
|
window['$message'].error(res.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话列表右键显示菜单
|
||||||
|
const onContextMenu = (e: any, item: Item) => {
|
||||||
|
if (batchDelete.value == true || item.leader == 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.show = false
|
||||||
|
dropdown.item = Object.assign({}, item)
|
||||||
|
dropdown.options = [
|
||||||
|
{
|
||||||
|
label: '查看成员',
|
||||||
|
key: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: item.is_mute ? '解除禁言' : '禁止发言',
|
||||||
|
key: 'forbidden'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '删除成员',
|
||||||
|
key: 'delete'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '批量删除',
|
||||||
|
key: 'batch_delete'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isAdmin.value) {
|
||||||
|
dropdown.options.push({ label: '转让群主', key: 'transfer' })
|
||||||
|
|
||||||
|
if (item.leader == 1) {
|
||||||
|
dropdown.options.push({ label: '管理权限(解除)', key: 'assignment' })
|
||||||
|
} else if (item.leader == 0) {
|
||||||
|
dropdown.options.push({ label: '管理权限(分配)', key: 'assignment' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
dropdown.show = true
|
||||||
|
dropdown.dropdownX = e.clientX
|
||||||
|
dropdown.dropdownY = e.clientY
|
||||||
|
})
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onContextMenuHandle = (key: string) => {
|
||||||
|
// 注册回调事件
|
||||||
|
const evnets = {
|
||||||
|
info: onUserInfo,
|
||||||
|
assignment: onAssignAdmin,
|
||||||
|
transfer: onTransfer,
|
||||||
|
forbidden: onForbidden,
|
||||||
|
delete: onDelete,
|
||||||
|
batch_delete: () => {
|
||||||
|
batchDelete.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.show = false
|
||||||
|
evnets[key] && evnets[key](dropdown.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoadData()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="el-container is-vertical height100">
|
||||||
|
<header class="el-header header bdr-b">
|
||||||
|
<p>成员管理({{ filterSearch.length }})</p>
|
||||||
|
<div>
|
||||||
|
<n-space>
|
||||||
|
<n-input
|
||||||
|
placeholder="搜索"
|
||||||
|
v-model:value.trim="keywords"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<n-icon :component="Search" />
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
|
||||||
|
<n-button circle @click="isGroupLaunch = true">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="Plus" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main v-if="filterSearch.length === 0" class="el-main main flex-center">
|
||||||
|
<n-empty size="200" description="暂无相关数据">
|
||||||
|
<template #icon>
|
||||||
|
<img src="@/assets/image/no-data.svg" alt="" />
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else class="el-main main me-scrollbar me-scrollbar-thumb">
|
||||||
|
<div class="member-item" v-for="item in filterSearch" :key="item.user_id">
|
||||||
|
<div class="tool flex-center" v-show="batchDelete">
|
||||||
|
<n-checkbox :disabled="item.leader === 2" size="small" :checked="item.is_delete" />
|
||||||
|
</div>
|
||||||
|
<div class="avatar pointer" @click="onUserInfo(item)">
|
||||||
|
<im-avatar :size="40" :src="item.avatar" :username="item.nickname" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="content pointer o-hidden"
|
||||||
|
@click="onRowClick(item)"
|
||||||
|
@contextmenu.prevent="onContextMenu($event, item)"
|
||||||
|
>
|
||||||
|
<div class="item-title">
|
||||||
|
<p class="nickname text-ellipsis">
|
||||||
|
<span>{{ item.nickname || '未设置昵称' }}</span>
|
||||||
|
<span v-show="item.remark"> ({{ item.remark }})</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="badge master" v-show="item.leader == 2">群主</span>
|
||||||
|
<span class="badge leader" v-show="item.leader == 1">管理员</span>
|
||||||
|
<span class="badge muted" v-show="item.is_mute == 1">已禁言</span>
|
||||||
|
<!-- <span class="badge qiye">企业</span> -->
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="item-text text-ellipsis">
|
||||||
|
{{ item.motto || '暂无简介' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="el-footer footer bdr-t" v-show="batchDelete">
|
||||||
|
<div class="tips">已选({{ filterCheck.length }})</div>
|
||||||
|
<div>
|
||||||
|
<n-space>
|
||||||
|
<n-button type="primary" ghost size="small" @click="onCancelDelete"> 取消 </n-button>
|
||||||
|
<n-button color="red" size="small" @click="onBatchDelete"> 批量删除 </n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<n-dropdown
|
||||||
|
:show="dropdown.show"
|
||||||
|
:x="dropdown.dropdownX"
|
||||||
|
:y="dropdown.dropdownY"
|
||||||
|
placement="right"
|
||||||
|
:options="dropdown.options"
|
||||||
|
@select="onContextMenuHandle"
|
||||||
|
@clickoutside="
|
||||||
|
() => {
|
||||||
|
dropdown.show = false
|
||||||
|
dropdown.item = {}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GroupLaunch
|
||||||
|
v-if="isGroupLaunch"
|
||||||
|
:gid="id"
|
||||||
|
@close="isGroupLaunch = false"
|
||||||
|
@on-invite="onLoadData"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.header {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
box-sizing: content-box;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
height: 28px;
|
||||||
|
width: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-text {
|
||||||
|
width: inherit;
|
||||||
|
height: 20px;
|
||||||
|
color: rgb(255 255 255 / 52%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.item-title {
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
width: 25px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
border-bottom-right-radius: 15px;
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
margin-left: 3px;
|
||||||
|
&.master {
|
||||||
|
color: #dc9b04 !important;
|
||||||
|
background-color: #faf1d1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.leader {
|
||||||
|
color: #3370ff;
|
||||||
|
background-color: #e1eaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.qiye {
|
||||||
|
background-color: #2196f3;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
background-color: #a9a9ae;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.badge {
|
||||||
|
&.muted {
|
||||||
|
background-color: #777782;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
127
src/components/group/manage/NoticeEditor.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { NModal, NForm, NFormItem, NInput } from 'naive-ui'
|
||||||
|
import { ServeEditGroupNotice } from '@/api/group'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'success'])
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
gid: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleModal = ref(props.id == 0 ? '发布群公告' : '编辑群公告')
|
||||||
|
|
||||||
|
const formRef = ref()
|
||||||
|
const model = reactive({
|
||||||
|
title: props.title,
|
||||||
|
content: props.content
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
title: {
|
||||||
|
required: true,
|
||||||
|
trigger: ['blur', 'input'],
|
||||||
|
message: '标题不能为空!'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
required: true,
|
||||||
|
trigger: ['blur', 'input'],
|
||||||
|
message: '内容不能为空!'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isShow = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
let response = ServeEditGroupNotice({
|
||||||
|
notice_id: props.id,
|
||||||
|
group_id: props.gid,
|
||||||
|
title: model.title,
|
||||||
|
content: model.content,
|
||||||
|
is_top: 0,
|
||||||
|
is_confirm: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
response.then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
window['$message'].success(res.message)
|
||||||
|
emit('success')
|
||||||
|
} else {
|
||||||
|
window['$message'].warning(res.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onValidate = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
formRef.value.validate((errors) => {
|
||||||
|
!errors && onSubmit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShow"
|
||||||
|
preset="card"
|
||||||
|
:title="titleModal"
|
||||||
|
size="huge"
|
||||||
|
style="max-width: 450px; border-radius: 10px"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
>
|
||||||
|
<n-form ref="formRef" :model="model" :rules="rules">
|
||||||
|
<n-form-item label="标题" path="title">
|
||||||
|
<n-input placeholder="必填" type="text" v-model:value="model.title" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="内容" path="content">
|
||||||
|
<n-input
|
||||||
|
placeholder="必填"
|
||||||
|
type="textarea"
|
||||||
|
v-model:value="model.content"
|
||||||
|
:autosize="{
|
||||||
|
minRows: 5,
|
||||||
|
maxRows: 10
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div style="width: 100%; text-align: right">
|
||||||
|
<n-button type="tertiary" @click="onMaskClick"> 取消 </n-button>
|
||||||
|
<n-button type="primary" class="mt-l15" :loading="loading" @click="onValidate">
|
||||||
|
确定
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped></style>
|
253
src/components/group/manage/NoticeTab.vue
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, reactive, onMounted } from 'vue'
|
||||||
|
import { NSpace, NEmpty } from 'naive-ui'
|
||||||
|
import { Search, Plus } from '@icon-park/vue-next'
|
||||||
|
import NoticeEditor from './NoticeEditor.vue'
|
||||||
|
import { ServeGetGroupNotices } from '@/api/group'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
is_confirm: number
|
||||||
|
is_top: number
|
||||||
|
creator_id: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
confirm_users: string
|
||||||
|
is_delete: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = ref('')
|
||||||
|
const batchDelete = ref(false)
|
||||||
|
const items = ref<Item[]>([])
|
||||||
|
const editor = reactive({
|
||||||
|
isShow: false,
|
||||||
|
id: 0,
|
||||||
|
gid: 0,
|
||||||
|
title: '',
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterCheck = computed(() => {
|
||||||
|
return items.value.filter((item: Item) => item.is_delete)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterSearch = computed(() => {
|
||||||
|
if (!keywords.value.length) {
|
||||||
|
return items.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.value.filter((item: Item) => {
|
||||||
|
return item.title.match(keywords.value) != null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onLoadData = () => {
|
||||||
|
ServeGetGroupNotices({
|
||||||
|
group_id: props.id
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
items.value = res.data.items || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBatchDelete = () => {
|
||||||
|
if (!filterCheck.value.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRowClick = (item: any) => {
|
||||||
|
if (batchDelete.value == true) {
|
||||||
|
console.log(item)
|
||||||
|
} else {
|
||||||
|
editor.id = item.id
|
||||||
|
editor.gid = props.id
|
||||||
|
editor.title = item.title
|
||||||
|
editor.content = item.content
|
||||||
|
editor.isShow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
editor.id = 0
|
||||||
|
editor.gid = props.id
|
||||||
|
editor.title = ''
|
||||||
|
editor.content = ''
|
||||||
|
editor.isShow = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancelDelete = () => {
|
||||||
|
items.value.forEach((item: Item) => {
|
||||||
|
item.is_delete = false
|
||||||
|
})
|
||||||
|
|
||||||
|
batchDelete.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEditorSuccess = () => {
|
||||||
|
editor.isShow = false
|
||||||
|
onLoadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onLoadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="section el-container is-vertical height100">
|
||||||
|
<header class="el-header header bdr-b">
|
||||||
|
<p>公告管理({{ filterSearch.length }})</p>
|
||||||
|
<div>
|
||||||
|
<n-space>
|
||||||
|
<n-input
|
||||||
|
placeholder="搜索"
|
||||||
|
v-model:value.trim="keywords"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<n-icon :component="Search" />
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
|
||||||
|
<n-button circle @click="onAdd">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="Plus" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main v-if="filterSearch.length === 0" class="el-main main flex-center">
|
||||||
|
<n-empty size="200" description="暂无相关数据">
|
||||||
|
<template #icon>
|
||||||
|
<img src="@/assets/image/no-data.svg" />
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else class="el-main main me-scrollbar me-scrollbar-thumb">
|
||||||
|
<div
|
||||||
|
class="member-item bdr-b"
|
||||||
|
v-for="item in filterSearch"
|
||||||
|
:key="item.id"
|
||||||
|
@click="onRowClick(item)"
|
||||||
|
>
|
||||||
|
<div class="content pointer o-hidden">
|
||||||
|
<div class="item-title">
|
||||||
|
<p class="nickname text-ellipsis">
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="date">{{ item.updated_at }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="item-text text-ellipsis">{{ item.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="el-footer footer bdr-t" v-show="batchDelete">
|
||||||
|
<div class="tips">已选({{ filterCheck.length }})</div>
|
||||||
|
<div>
|
||||||
|
<n-space>
|
||||||
|
<n-button type="primary" ghost size="small" @click="onCancelDelete"> 取消 </n-button>
|
||||||
|
<n-button type="error" size="small" @click="onBatchDelete"> 删除 </n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NoticeEditor
|
||||||
|
v-if="editor.isShow"
|
||||||
|
:id="editor.id"
|
||||||
|
:gid="editor.gid"
|
||||||
|
:title="editor.title"
|
||||||
|
:content="editor.content"
|
||||||
|
@success="onEditorSuccess"
|
||||||
|
@close="editor.isShow = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.header {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 0 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
|
padding: 5px 0 15px 0;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
height: inherit;
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
height: 30px;
|
||||||
|
width: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: #989898;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-text {
|
||||||
|
width: inherit;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--im-text-color-grey);
|
||||||
|
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.item-title {
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
background-color: #fdf9f9;
|
||||||
|
border-bottom-right-radius: 15px;
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
89
src/components/group/manage/index.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { NModal } from 'naive-ui'
|
||||||
|
import DetailTab from './DetailTab.vue'
|
||||||
|
import MemberTab from './MemberTab.vue'
|
||||||
|
import NoticeTab from './NoticeTab.vue'
|
||||||
|
import ApplyTab from './ApplyTab.vue'
|
||||||
|
import ConfigTab from './ConfigTab.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
gid: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isShowBox = ref(true)
|
||||||
|
const tabIndex = ref(0)
|
||||||
|
const menus = [
|
||||||
|
{ name: '群信息', component: DetailTab },
|
||||||
|
{ name: '群成员', component: MemberTab },
|
||||||
|
{ name: '群公告', component: NoticeTab },
|
||||||
|
{ name: '群申请', component: ApplyTab },
|
||||||
|
{ name: '群设置', component: ConfigTab }
|
||||||
|
]
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShowBox"
|
||||||
|
preset="card"
|
||||||
|
title="群管理"
|
||||||
|
class="modal-radius"
|
||||||
|
style="max-width: 800px"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
:segmented="{
|
||||||
|
content: true
|
||||||
|
}"
|
||||||
|
:content-style="{
|
||||||
|
padding: 0
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<section class="el-container container-box">
|
||||||
|
<aside class="el-aside bdr-r" style="width: 100px">
|
||||||
|
<div
|
||||||
|
v-for="(menu, index) in menus"
|
||||||
|
:key="menu.name"
|
||||||
|
class="menu-list pointer"
|
||||||
|
:class="{ selectd: tabIndex == index }"
|
||||||
|
v-text="menu.name"
|
||||||
|
@click="tabIndex = index"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="el-main">
|
||||||
|
<component :is="menus[tabIndex].component" :id="gid" @close="onMaskClick" />
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.container-box {
|
||||||
|
height: 550px;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-list {
|
||||||
|
height: 25px;
|
||||||
|
line-height: 25px;
|
||||||
|
margin: 16px 0px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 14px;
|
||||||
|
border-right: 3px solid transparent;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.selectd {
|
||||||
|
color: #2196f3;
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
124
src/components/talk/ForwardRecord.vue
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import Loading from '@/components/base/Loading.vue'
|
||||||
|
import { ServeGetForwardRecords } from '@/api/chat'
|
||||||
|
import { MessageComponents } from '@/constant/message'
|
||||||
|
import { ITalkRecord } from '@/types/chat'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const props = defineProps({
|
||||||
|
msgId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
const isShow = ref(true)
|
||||||
|
const items = ref<ITalkRecord[]>([])
|
||||||
|
const title = ref('会话记录')
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadData = () => {
|
||||||
|
ServeGetForwardRecords({
|
||||||
|
msg_id: props.msgId
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
items.value = res.data.items || []
|
||||||
|
|
||||||
|
title.value = `会话记录(${items.value.length})`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onLoadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShow"
|
||||||
|
preset="card"
|
||||||
|
:title="title"
|
||||||
|
style="max-width: 500px"
|
||||||
|
class="modal-radius"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
:segmented="{
|
||||||
|
content: true
|
||||||
|
}"
|
||||||
|
:header-style="{
|
||||||
|
padding: '20px 15px'
|
||||||
|
}"
|
||||||
|
:content-style="{
|
||||||
|
padding: 0
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="main-box 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.user_id)">
|
||||||
|
<im-avatar :src="item.avatar" :size="30" :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
|
||||||
|
:is="MessageComponents[item.msg_type] || 'unknown-message'"
|
||||||
|
:extra="item.extra"
|
||||||
|
:data="item"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.main-box {
|
||||||
|
height: 600px;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
min-height: 38px;
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 5px 15px;
|
||||||
|
|
||||||
|
.left-box {
|
||||||
|
width: 30px;
|
||||||
|
display: flex;
|
||||||
|
user-select: none;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-box {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0px 5px 15px 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
.msg-header {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
281
src/components/talk/HistoryRecord.vue
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import Loading from '@/components/base/Loading.vue'
|
||||||
|
import { ServeFindTalkRecords } from '@/api/chat'
|
||||||
|
import { Down, Calendar } from '@icon-park/vue-next'
|
||||||
|
import * as message from '@/constant/message'
|
||||||
|
import { ITalkRecord } from '@/types/chat'
|
||||||
|
import { useInject } from '@/hooks'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const props = defineProps({
|
||||||
|
talkType: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
receiverId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const { showUserInfoModal } = useInject()
|
||||||
|
const model = reactive({
|
||||||
|
cursor: 0,
|
||||||
|
limit: 30,
|
||||||
|
msgType: 0,
|
||||||
|
loading: false,
|
||||||
|
loadMore: false,
|
||||||
|
isLoadMore: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const isShow = ref(true)
|
||||||
|
const items = ref<ITalkRecord[]>([])
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ name: '全部', type: 0, show: true },
|
||||||
|
{ name: '图片', type: message.ChatMsgTypeImage, show: true },
|
||||||
|
{ name: '音频', type: message.ChatMsgTypeAudio, show: true },
|
||||||
|
{ name: '视频', type: message.ChatMsgTypeVideo, show: true },
|
||||||
|
{ name: '文件', type: message.ChatMsgTypeFile, show: true },
|
||||||
|
{ name: '会话', type: message.ChatMsgTypeForward, show: true },
|
||||||
|
{ name: '代码', type: message.ChatMsgTypeCode, show: true },
|
||||||
|
{ name: '位置', type: message.ChatMsgTypeLocation, show: true },
|
||||||
|
{ name: '群投票', type: message.ChatMsgTypeVote, show: props.talkType == 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChatRecord = () => {
|
||||||
|
let data = {
|
||||||
|
talk_type: props.talkType,
|
||||||
|
receiver_id: props.receiverId,
|
||||||
|
msg_type: model.msgType,
|
||||||
|
cursor: model.cursor,
|
||||||
|
limit: model.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.cursor === 0) {
|
||||||
|
model.loading = true
|
||||||
|
} else {
|
||||||
|
model.loadMore = true
|
||||||
|
}
|
||||||
|
|
||||||
|
ServeFindTalkRecords(data).then((res) => {
|
||||||
|
if (res.code != 200) return
|
||||||
|
|
||||||
|
if (data.cursor === 0) {
|
||||||
|
items.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = res.data.items || []
|
||||||
|
if (list.length) {
|
||||||
|
model.cursor = res.data.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
model.loading = false
|
||||||
|
model.loadMore = false
|
||||||
|
model.isLoadMore = list.length >= model.limit
|
||||||
|
items.value.push(...list)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerType = (type: number) => {
|
||||||
|
model.msgType = type
|
||||||
|
model.cursor = 0
|
||||||
|
loadChatRecord()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadChatRecord()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="isShow"
|
||||||
|
preset="card"
|
||||||
|
title="消息管理"
|
||||||
|
style="max-width: 750px"
|
||||||
|
class="modal-radius"
|
||||||
|
:on-after-leave="onMaskClick"
|
||||||
|
:segmented="{
|
||||||
|
content: true
|
||||||
|
}"
|
||||||
|
:header-style="{
|
||||||
|
padding: '20px 15px'
|
||||||
|
}"
|
||||||
|
:content-style="{
|
||||||
|
padding: 0
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<section class="main-box el-container is-vertical o-hidden">
|
||||||
|
<header class="el-header bdr-b search" style="height: 50px">
|
||||||
|
<div class="type-items">
|
||||||
|
<span
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.name"
|
||||||
|
class="pointer"
|
||||||
|
:class="{ active: model.msgType == tab.type }"
|
||||||
|
@click="triggerType(tab.type)"
|
||||||
|
v-show="tab.show"
|
||||||
|
>
|
||||||
|
{{ tab.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<!-- <n-popover placement="bottom-end" trigger="click" :show-arrow="false">
|
||||||
|
<template #trigger>
|
||||||
|
<n-icon
|
||||||
|
:size="20"
|
||||||
|
class="pointer"
|
||||||
|
:component="Calendar"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<n-date-picker
|
||||||
|
panel
|
||||||
|
type="date"
|
||||||
|
:is-date-disabled="disablePreviousDate"
|
||||||
|
:on-update:value="datefunc"
|
||||||
|
/>
|
||||||
|
</n-popover> -->
|
||||||
|
|
||||||
|
<n-icon :size="20" class="pointer" :component="Calendar" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main v-if="model.loading" class="el-main flex-center">
|
||||||
|
<Loading />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else-if="items.length === 0" class="el-main flex-center">
|
||||||
|
<n-empty size="200" description="暂无相关数据">
|
||||||
|
<template #icon>
|
||||||
|
<img src="@/assets/image/no-data.svg" alt="" />
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else class="el-main me-scrollbar me-scrollbar-thumb">
|
||||||
|
<div v-for="item in items" :key="item.id" class="message-item">
|
||||||
|
<div class="left-box">
|
||||||
|
<im-avatar
|
||||||
|
:src="item.avatar"
|
||||||
|
:size="30"
|
||||||
|
:username="item.nickname"
|
||||||
|
@click="showUserInfoModal(item.user_id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-box me-scrollbar">
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="name">{{ item.nickname }}</span>
|
||||||
|
<span class="time"> {{ item.created_at }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="item.is_revoke == 1">
|
||||||
|
<div class="msg-content">此消息已被撤回</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<component
|
||||||
|
v-if="item.is_revoke == 0"
|
||||||
|
:is="message.MessageComponents[item.msg_type] || 'unknown-message'"
|
||||||
|
:extra="item.extra"
|
||||||
|
:data="item"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="more pointer flex-center" @click="loadChatRecord" v-show="model.isLoadMore">
|
||||||
|
<n-icon v-show="!model.loadMore" :size="20" class="icon" :component="Down" />
|
||||||
|
<span> {{ model.loadMore ? '数据加载中...' : '加载更多' }} </span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.main-box {
|
||||||
|
height: 550px;
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px 0 5px;
|
||||||
|
|
||||||
|
.type-items {
|
||||||
|
line-height: 40px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: #03a9f4;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
height: 40px;
|
||||||
|
width: 45px;
|
||||||
|
margin: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
min-height: 30px;
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 5px 15px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-box {
|
||||||
|
width: 30px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-box {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0px 5px 15px 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
.msg-header {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
margin: 10px auto 20px;
|
||||||
|
width: 150px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
260
src/components/talk/message/AudioMessage.vue
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { PlayOne, PauseOne } from '@icon-park/vue-next'
|
||||||
|
import { ITalkRecordExtraAudio, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
extra: ITalkRecordExtraAudio
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const audioRef = ref()
|
||||||
|
|
||||||
|
const durationDesc = ref('-')
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
isAudioPlay: false,
|
||||||
|
progress: 0,
|
||||||
|
duration: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
loading: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const onPlay = () => {
|
||||||
|
if (state.isAudioPlay) {
|
||||||
|
audioRef.value.pause()
|
||||||
|
} else {
|
||||||
|
audioRef.value.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
state.isAudioPlay = !state.isAudioPlay
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPlayEnd = () => {
|
||||||
|
state.isAudioPlay = false
|
||||||
|
state.progress = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanplay = () => {
|
||||||
|
state.duration = audioRef.value.duration
|
||||||
|
durationDesc.value = formatTime(parseInt(audioRef.value.duration))
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onError = (e: any) => {
|
||||||
|
console.log('音频播放异常===>', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
let audio = audioRef.value
|
||||||
|
if (audio.duration == 0) {
|
||||||
|
state.progress = 0
|
||||||
|
} else {
|
||||||
|
state.currentTime = audio.currentTime
|
||||||
|
state.progress = (audio.currentTime / audio.duration) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (value: number = 0) => {
|
||||||
|
if (value == 0) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(value / 60)
|
||||||
|
let seconds = value
|
||||||
|
if (minutes > 0) {
|
||||||
|
seconds = Math.floor(value - minutes * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes}'${seconds}"`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="im-message-audio">
|
||||||
|
<audio
|
||||||
|
ref="audioRef"
|
||||||
|
preload="auto"
|
||||||
|
type="audio/mp3,audio/wav"
|
||||||
|
:src="extra.url"
|
||||||
|
@timeupdate="onTimeUpdate"
|
||||||
|
@ended="onPlayEnd"
|
||||||
|
@canplay="onCanplay"
|
||||||
|
@error="onError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="play">
|
||||||
|
<div class="btn pointer" @click.stop="onPlay">
|
||||||
|
<n-icon :size="18" :component="state.isAudioPlay ? PauseOne : PlayOne" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="desc">
|
||||||
|
<span class="line" v-for="i in 23" :key="i"></span>
|
||||||
|
<span
|
||||||
|
class="indicator"
|
||||||
|
:style="{ left: state.progress + '%' }"
|
||||||
|
v-show="state.progress > 0"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="time">{{ durationDesc }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-audio {
|
||||||
|
--audio-bg-color: #f5f5f5;
|
||||||
|
--audio-btn-bg-color: #ffffff;
|
||||||
|
|
||||||
|
width: 200px;
|
||||||
|
height: 45px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--audio-bg-color);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
width: 45px;
|
||||||
|
height: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background-color: var(--audio-btn-bg-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: rgb(24, 24, 24);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
flex: 1 1;
|
||||||
|
height: inherit;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.line {
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 30px;
|
||||||
|
width: 2px;
|
||||||
|
background-color: rgb(40, 39, 39);
|
||||||
|
margin-left: 3px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(1) {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
&:nth-child(2) {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
&:nth-child(4) {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
&:nth-child(5) {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
&:nth-child(6) {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
&:nth-child(7) {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(8) {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
&:nth-child(9) {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
&:nth-child(10) {
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
&:nth-child(11) {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
&:nth-child(12) {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
&:nth-child(13) {
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
&:nth-child(14) {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
&:nth-child(15) {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
&:nth-child(16) {
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
&:nth-child(17) {
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
&:nth-child(18) {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
&:nth-child(19) {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
&:nth-child(20) {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
&:nth-child(21) {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
&:nth-child(22) {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
&:nth-child(23) {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
position: absolute;
|
||||||
|
height: 70%;
|
||||||
|
width: 1px;
|
||||||
|
background-color: #9b9595;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
width: 50px;
|
||||||
|
height: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.im-message-audio {
|
||||||
|
--audio-bg-color: #2c2c32;
|
||||||
|
--audio-btn-bg-color: rgb(78, 75, 75);
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
.line {
|
||||||
|
background-color: rgb(169, 167, 167);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
127
src/components/talk/message/CodeMessage.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { NCode } from 'naive-ui'
|
||||||
|
import { Copy, Stretching } from '@icon-park/vue-next'
|
||||||
|
import { clipboard } from '@/utils/common'
|
||||||
|
import { useUtil } from '@/hooks'
|
||||||
|
import { ITalkRecordExtraCode, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
extra: ITalkRecordExtraCode
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { useMessage } = useUtil()
|
||||||
|
const lineMumber = props.extra.code.trim().split('\n').length
|
||||||
|
const full = ref(false)
|
||||||
|
|
||||||
|
const onClipboard = () => {
|
||||||
|
clipboard(props.extra.code, () => {
|
||||||
|
useMessage.success('复制成功')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="im-message-code el-container is-vertical"
|
||||||
|
:class="{
|
||||||
|
maxwidth: maxWidth,
|
||||||
|
full: full
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<header class="el-header tools">
|
||||||
|
<p># {{ extra.lang }}</p>
|
||||||
|
<p>
|
||||||
|
<n-icon class="icon" :component="Stretching" @click="full = !full" />
|
||||||
|
<n-icon class="icon" :component="Copy" @click="onClipboard" />
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<main class="el-main me-scrollbar me-scrollbar-thumb" :lineMumber="lineMumber">
|
||||||
|
<n-code :language="extra.lang" :code="extra.code" show-line-numbers />
|
||||||
|
<div class="el-footer mask pointer" v-show="lineMumber > 20" @click="full = !full">
|
||||||
|
查看更多
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-code {
|
||||||
|
min-width: 300px;
|
||||||
|
min-height: 100px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 5px 8px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
flex: unset;
|
||||||
|
|
||||||
|
.el-main {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.maxwidth {
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.full {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: var(--im-bg-color);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: unset;
|
||||||
|
max-height: unset;
|
||||||
|
overflow-y: unset;
|
||||||
|
border-radius: unset;
|
||||||
|
|
||||||
|
.el-main {
|
||||||
|
overflow-y: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 8px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
height: 80px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 10;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||||
|
color: var(--im-text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.im-message-code {
|
||||||
|
background: var(--im-message-bg-color);
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
background: linear-gradient(to bottom, transparent 0%, var(--im-bg-color) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
118
src/components/talk/message/FileMessage.vue
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { fileFormatSize } from '@/utils/strings'
|
||||||
|
import { download, getFileNameSuffix } from '@/utils/functions'
|
||||||
|
import { ITalkRecordExtraFile, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
extra: ITalkRecordExtraFile
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="file-message">
|
||||||
|
<div class="main">
|
||||||
|
<div class="ext">{{ getFileNameSuffix(extra.name) }}</div>
|
||||||
|
<div class="file-box">
|
||||||
|
<p class="info">
|
||||||
|
<span class="name">{{ extra.name }}</span>
|
||||||
|
<span class="size">({{ fileFormatSize(extra.size) }})</span>
|
||||||
|
</p>
|
||||||
|
<p class="notice">文件已成功发送, 文件助手永久保存</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<a @click="download(data.msg_id)">下载</a>
|
||||||
|
<a>在线预览</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.file-message {
|
||||||
|
width: 250px;
|
||||||
|
min-height: 85px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--im-message-border-color);
|
||||||
|
|
||||||
|
.main {
|
||||||
|
height: 45px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin-top: 5px;
|
||||||
|
|
||||||
|
.ext {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #49a4ff;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-box {
|
||||||
|
flex: 1 1;
|
||||||
|
height: 45px;
|
||||||
|
margin-left: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex: 1 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cac6c6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
height: 25px;
|
||||||
|
line-height: 25px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #929191;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 37px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin: 0 3px;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--im-text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: royalblue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
84
src/components/talk/message/ForwardMessage.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import ForwardRecord from '../ForwardRecord.vue'
|
||||||
|
import { ITalkRecordExtraForward, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
extra: ITalkRecordExtraForward
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isShowRecord = ref(false)
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
return [...new Set(props.extra.records.map((v) => v.nickname))].join('、')
|
||||||
|
})
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
isShowRecord.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="im-message-forward pointer" @click="onClick">
|
||||||
|
<div class="title">{{ title }} 的会话记录</div>
|
||||||
|
<div class="list" v-for="(record, index) in extra.records" :key="index">
|
||||||
|
<p>
|
||||||
|
<span>{{ record.nickname }}: </span>
|
||||||
|
<span>{{ record.text }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tips">
|
||||||
|
<span>转发:聊天会话记录 ({{ extra.msg_ids.length }}条)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ForwardRecord v-if="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-forward {
|
||||||
|
width: 250px;
|
||||||
|
min-height: 95px;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--im-message-border-color);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-size: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list p {
|
||||||
|
height: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a8a8a8;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
height: 32px;
|
||||||
|
line-height: 35px;
|
||||||
|
color: #8a8888;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
60
src/components/talk/message/GroupNoticeMessage.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ITalkRecordExtraGroupNotice, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
extra: ITalkRecordExtraGroupNotice
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
let show = ref(false)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="im-message-group-notice pointer" @click="show = !show">
|
||||||
|
<div class="title">
|
||||||
|
<n-tag :bordered="false" size="small" type="primary"> 群公告 </n-tag>
|
||||||
|
《{{ extra.title }}》
|
||||||
|
</div>
|
||||||
|
<div class="content" :class="{ ellipsis: !show }">
|
||||||
|
{{ extra.content }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-group-notice {
|
||||||
|
max-width: 500px;
|
||||||
|
min-height: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--im-message-border-color);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #a8a8a8;
|
||||||
|
line-height: 24px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
&.ellipsis {
|
||||||
|
height: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
60
src/components/talk/message/ImageMessage.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { NImage } from 'naive-ui'
|
||||||
|
import { getImageInfo } from '@/utils/functions'
|
||||||
|
import { ITalkRecordExtraImage, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
extra: ITalkRecordExtraImage
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const img = (src: string, width = 200) => {
|
||||||
|
const info = getImageInfo(src)
|
||||||
|
|
||||||
|
if (info.width == 0 || info.height == 0) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.width < width) {
|
||||||
|
return {
|
||||||
|
width: `${info.width}px`,
|
||||||
|
height: `${info.height}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: width + 'px',
|
||||||
|
height: `${info.height / (info.width / width)}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="im-message-image"
|
||||||
|
:class="{ left: data.float === 'left' }"
|
||||||
|
:style="img(extra.url, 350)"
|
||||||
|
>
|
||||||
|
<n-image :src="extra.url" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-image {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--im-message-left-bg-color);
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
background: var(--im-message-right-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-image img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
67
src/components/talk/message/LoginMessage.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ITalkRecordExtraLogin, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
extra: ITalkRecordExtraLogin
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function getExploreName(userAgent = '') {
|
||||||
|
if (userAgent.indexOf('Opera') > -1 || userAgent.indexOf('OPR') > -1) {
|
||||||
|
return 'Opera'
|
||||||
|
} else if (userAgent.indexOf('compatible') > -1 && userAgent.indexOf('MSIE') > -1) {
|
||||||
|
return 'IE'
|
||||||
|
} else if (userAgent.indexOf('Edge') > -1) {
|
||||||
|
return 'Edge'
|
||||||
|
} else if (userAgent.indexOf('Firefox') > -1) {
|
||||||
|
return 'Firefox'
|
||||||
|
} else if (userAgent.indexOf('Safari') > -1 && userAgent.indexOf('Chrome') == -1) {
|
||||||
|
return 'Safari'
|
||||||
|
} else if (userAgent.indexOf('Chrome') > -1 && userAgent.indexOf('Safari') > -1) {
|
||||||
|
return 'Chrome'
|
||||||
|
} else {
|
||||||
|
return 'Unkonwn'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExploreOs(userAgent = '') {
|
||||||
|
if (userAgent.indexOf('Mac OS') > -1) {
|
||||||
|
return 'Mac OS'
|
||||||
|
} else {
|
||||||
|
return 'Windows'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="im-message-login">
|
||||||
|
<h4>登录操作通知</h4>
|
||||||
|
<p>登录时间:{{ extra.datetime }} (CST)</p>
|
||||||
|
<p>IP 地址:{{ extra.ip }}</p>
|
||||||
|
<p>登录地点:{{ extra.address }}</p>
|
||||||
|
<p>
|
||||||
|
登录设备:{{ getExploreName(extra.agent) }} /
|
||||||
|
{{ getExploreOs(extra.agent) }}
|
||||||
|
</p>
|
||||||
|
<p>异常原因:{{ extra.reason }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-login {
|
||||||
|
width: 300px;
|
||||||
|
min-height: 50px;
|
||||||
|
background: var(--im-message-bg-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
color: var(--im-text-color);
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 10px 0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
105
src/components/talk/message/MixedMessage.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { NImage } from 'naive-ui'
|
||||||
|
import { textReplaceEmoji } from '@/utils/emojis'
|
||||||
|
import { textReplaceLink } from '@/utils/strings'
|
||||||
|
import { getImageInfo } from '@/utils/functions'
|
||||||
|
import { ITalkRecordExtraMixed, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
extra: ITalkRecordExtraMixed
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const float = props.data.float
|
||||||
|
|
||||||
|
const img = (src, width = 200) => {
|
||||||
|
const info = getImageInfo(src)
|
||||||
|
|
||||||
|
if (info.width == 0 || info.height == 0) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.width < width) {
|
||||||
|
return {
|
||||||
|
width: `${info.width}px`,
|
||||||
|
height: `${info.height}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let h = info.height / (info.width / width)
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: width + 'px',
|
||||||
|
height: h + 'px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="im-message-mixed"
|
||||||
|
:class="{
|
||||||
|
left: float == 'left',
|
||||||
|
right: float == 'right',
|
||||||
|
maxwidth: maxWidth
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<pre>
|
||||||
|
<template v-for="(item) in extra.items" :key="item.id">
|
||||||
|
|
||||||
|
<template v-if="item.type === 1">
|
||||||
|
<span v-html="textReplaceEmoji(textReplaceLink(item.content))" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="item.type === 3">
|
||||||
|
<div
|
||||||
|
:style="img(item.content, 300)"
|
||||||
|
style="display: flex; margin: 5px 0;border-radius: 8px;overflow: hidden;;"
|
||||||
|
>
|
||||||
|
<n-image :src="item.content"></n-image>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-mixed {
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 3px;
|
||||||
|
color: var(--im-message-left-text-color);
|
||||||
|
background: var(--im-message-left-bg-color);
|
||||||
|
border-radius: 0px 10px 10px 10px;
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
background-color: var(--im-message-right-bg-color);
|
||||||
|
color: var(--im-message-right-text-color);
|
||||||
|
border-radius: 10px 0px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.maxwidth {
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
|
||||||
|
line-height: 25px;
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
color: #2196f3;
|
||||||
|
text-decoration: revert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
71
src/components/talk/message/RevokeMessage.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
import { formatTime } from '@/utils/datetime'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
login_uid: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
talk_type: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
nickname: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
datetime: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="im-message-revoke">
|
||||||
|
<div class="content">
|
||||||
|
<span v-if="login_uid == user_id"> 你撤回了一条消息 | {{ formatTime(datetime) }} </span>
|
||||||
|
<span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span>
|
||||||
|
<span v-else>
|
||||||
|
"{{ nickname }}" 撤回了一条消息 |
|
||||||
|
{{ formatTime(datetime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-revoke {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin: 10px auto;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
word-wrap: break-word;
|
||||||
|
color: #979191;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 300;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[theme-mode='dark'] {
|
||||||
|
.im-message-revoke {
|
||||||
|
.content {
|
||||||
|
background: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
84
src/components/talk/message/TextMessage.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<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, '#1890ff')
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 3px;
|
||||||
|
color: var(--im-message-left-text-color);
|
||||||
|
background: var(--im-message-left-bg-color);
|
||||||
|
border-radius: 0px 10px 10px 10px;
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
background-color: var(--im-message-right-bg-color);
|
||||||
|
color: var(--im-message-right-text-color);
|
||||||
|
border-radius: 10px 0px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.maxwidth {
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.radius-reset {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
|
||||||
|
line-height: 25px;
|
||||||
|
|
||||||
|
:deep(.emoji) {
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
color: #2196f3;
|
||||||
|
text-decoration: revert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
23
src/components/talk/message/UnknownMessage.vue
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
extra: Object,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="im-message-unknown">[{{ data.msg_type }}] 未知消息类型</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-unknown {
|
||||||
|
height: 35px;
|
||||||
|
line-height: 35px;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #979191;
|
||||||
|
background: #eff0f1;
|
||||||
|
width: 150px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
</style>
|
137
src/components/talk/message/VideoMessage.vue
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import 'xgplayer/dist/index.min.css'
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { NImage, NModal, NCard } from 'naive-ui'
|
||||||
|
import { Play, Close } from '@icon-park/vue-next'
|
||||||
|
import { getImageInfo } from '@/utils/functions'
|
||||||
|
import Player from 'xgplayer'
|
||||||
|
import { ITalkRecordExtraVideo, ITalkRecord } from '@/types/chat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
extra: ITalkRecordExtraVideo
|
||||||
|
data: ITalkRecord
|
||||||
|
maxWidth?: Boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const img = (src: string, width = 200) => {
|
||||||
|
const info: any = getImageInfo(src)
|
||||||
|
|
||||||
|
if (info.width == 0 || info.height == 0) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.height > 300) {
|
||||||
|
return {
|
||||||
|
height: '300px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.width < width) {
|
||||||
|
return {
|
||||||
|
width: `${info.width}px`,
|
||||||
|
height: `${info.height}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: width + 'px',
|
||||||
|
height: info.height / (info.width / width) + 'px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
async function onPlay() {
|
||||||
|
open.value = true
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
new Player({
|
||||||
|
id: 'im-xgplayer',
|
||||||
|
url: props.extra.url,
|
||||||
|
fluid: true,
|
||||||
|
autoplay: true,
|
||||||
|
lang: 'zh-cn'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="im-message-video"
|
||||||
|
:class="{ left: data.float === 'left' }"
|
||||||
|
:style="img(extra.cover, 350)"
|
||||||
|
@click="onPlay"
|
||||||
|
>
|
||||||
|
<n-image :src="extra.cover" preview-disabled />
|
||||||
|
|
||||||
|
<div class="btn-video">
|
||||||
|
<n-icon :component="Play" size="36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-modal v-model:show="open">
|
||||||
|
<n-card
|
||||||
|
style="width: 800px; min-height: 300px; background-color: #ffffff; position: relative"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div id="im-xgplayer"></div>
|
||||||
|
<div class="im-xgplayer-close" @click="open = false">
|
||||||
|
<n-icon :component="Close" size="18" />
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-video {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--im-message-left-bg-color);
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
background: var(--im-message-right-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-image img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-video {
|
||||||
|
width: 30px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% - 15px);
|
||||||
|
top: calc(50% - 10px);
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.btn-video {
|
||||||
|
color: #03a9f4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.im-xgplayer-close {
|
||||||
|
position: absolute;
|
||||||
|
height: 35px;
|
||||||
|
width: 35px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
right: -45px;
|
||||||
|
top: -45px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
281
src/components/talk/message/VoteMessage.vue
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed, onMounted, ref } from 'vue'
|
||||||
|
import { NCheckbox, NProgress } from 'naive-ui'
|
||||||
|
import { ServeConfirmVoteHandle } from '@/api/chat'
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
extra: Object,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
const extra = ref(props.extra)
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const mode = extra.value.detail.answer_mode
|
||||||
|
const state = reactive({ options: [] })
|
||||||
|
|
||||||
|
// 是否可提交
|
||||||
|
const isCanSubmit = computed(() => {
|
||||||
|
return state.options.some((item) => item.is_checked)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否已投票
|
||||||
|
const isVoted = computed(() => {
|
||||||
|
return extra.value.vote_users.some((item) => item == userStore.uid)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置投票选项
|
||||||
|
*/
|
||||||
|
function setOptions(options) {
|
||||||
|
for (const option of options) {
|
||||||
|
state.options.push({
|
||||||
|
key: option.key,
|
||||||
|
value: option.value,
|
||||||
|
is_checked: false,
|
||||||
|
num: 0,
|
||||||
|
progress: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新统计信息
|
||||||
|
*
|
||||||
|
* @param {*} data
|
||||||
|
*/
|
||||||
|
function updateStatistics(data) {
|
||||||
|
let count = data.count
|
||||||
|
|
||||||
|
state.options.forEach((option) => {
|
||||||
|
option.num = data.options[option.key]
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
option.progress = (data.options[option.key] / count) * 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择投票
|
||||||
|
*
|
||||||
|
* @param {*} data
|
||||||
|
* @param {*} option
|
||||||
|
*/
|
||||||
|
function change(data, option) {
|
||||||
|
if (mode == 0) {
|
||||||
|
state.options.forEach((option) => (option.is_checked = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
option.is_checked = data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表单提交
|
||||||
|
*/
|
||||||
|
const onSubmit = () => {
|
||||||
|
if (!isCanSubmit.value) return
|
||||||
|
|
||||||
|
let items = []
|
||||||
|
|
||||||
|
state.options.forEach((item) => {
|
||||||
|
item.is_checked && items.push(item.key)
|
||||||
|
})
|
||||||
|
|
||||||
|
ServeConfirmVoteHandle({
|
||||||
|
msg_id: props.data.msg_id,
|
||||||
|
options: items.join(',')
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code == 200) {
|
||||||
|
updateStatistics(res.data)
|
||||||
|
extra.value.vote_users.push(userStore.uid)
|
||||||
|
extra.value.detail.answered_num++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setOptions(extra.value.detail.answer_option)
|
||||||
|
updateStatistics(extra.value.statistics)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="im-message-vote">
|
||||||
|
<div class="vote-from">
|
||||||
|
<div class="vheader">
|
||||||
|
<p style="font-weight: bold">
|
||||||
|
{{ mode == 1 ? '[多选投票]' : '[单选投票]' }}
|
||||||
|
</p>
|
||||||
|
<p>{{ extra.detail.title }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isVoted">
|
||||||
|
<div class="vbody">
|
||||||
|
<div class="vote-view" v-for="option in state.options" :key="option.key">
|
||||||
|
<p class="vote-option">{{ option.key }}、 {{ option.value }}</p>
|
||||||
|
<p class="vote-census">{{ option.num }} 票 {{ option.progress }}%</p>
|
||||||
|
<p class="vote-progress">
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:height="5"
|
||||||
|
:show-indicator="false"
|
||||||
|
:percentage="parseInt(option.progress)"
|
||||||
|
color="#1890ff"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vfooter vote-view">
|
||||||
|
<p>应参与人数:{{ extra.detail.answer_num }} 人</p>
|
||||||
|
<p>实际参与人数:{{ extra.detail.answered_num }} 人</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="vbody">
|
||||||
|
<div
|
||||||
|
class="option"
|
||||||
|
:class="{ radio: mode == 0 }"
|
||||||
|
v-for="option in state.options"
|
||||||
|
:key="option.key"
|
||||||
|
>
|
||||||
|
<p class="checkbox">
|
||||||
|
<n-checkbox
|
||||||
|
v-model:checked="option.is_checked"
|
||||||
|
@update:checked="change(option.is_checked, option)"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p class="text" @click="change(!option.is_checked, option)">
|
||||||
|
{{ option.key }}、{{ option.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vfooter">
|
||||||
|
<n-button plain round @click="onSubmit">
|
||||||
|
{{ isCanSubmit ? '立即投票' : '请选择进行投票' }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.im-message-vote {
|
||||||
|
width: 300px;
|
||||||
|
min-height: 150px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.vote-from {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.vheader {
|
||||||
|
min-height: 50px;
|
||||||
|
background: #4e83fd;
|
||||||
|
padding: 15px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 3px 0;
|
||||||
|
&:first-child {
|
||||||
|
color: rgb(245, 237, 237);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '投票';
|
||||||
|
position: absolute;
|
||||||
|
font-size: 60px;
|
||||||
|
color: white;
|
||||||
|
opacity: 0.1;
|
||||||
|
top: -5px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vbody {
|
||||||
|
min-height: 80px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.option {
|
||||||
|
margin: 14px 0px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin-left: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.radio {
|
||||||
|
:deep(.n-checkbox-box) {
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vfooter {
|
||||||
|
height: 55px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.n-button {
|
||||||
|
width: 90%;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.vote-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-left: 15px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
border-left: 2px solid #2196f3;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-view {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
margin: 15px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
> p {
|
||||||
|
margin: 6px 0px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-option {
|
||||||
|
min-height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-census {
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
11
src/components/talk/message/index.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
|
export function setComponents(app) {
|
||||||
|
// 动态导出当前目录下的组件
|
||||||
|
const modules = import.meta.glob(['./*.vue', './system/*.vue'])
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(modules)) {
|
||||||
|
const name = key.slice(key.lastIndexOf('/') + 1, key.lastIndexOf('.'))
|
||||||
|
app.component(name, defineAsyncComponent(value))
|
||||||
|
}
|
||||||
|
}
|
@ -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">
|
||||||
|
<a @click="showUserInfoModal(extra.owner_id)">
|
||||||
|
{{ extra.owner_name }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span>取消了全员禁言</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
28
src/components/talk/message/system/SysGroupCreateMessage.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<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">
|
||||||
|
<a @click="showUserInfoModal(extra.owner_id)">
|
||||||
|
{{ extra.owner_name }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span>创建了群聊,并邀请了</span>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|