初始化
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>
|