初始化

This commit is contained in:
scout 2024-12-24 16:14:21 +08:00
commit ba78899def
237 changed files with 32035 additions and 0 deletions

8
.env Normal file
View File

@ -0,0 +1,8 @@
ENV = 'development'
VITE_BASE=/
VUE_APP_PREVIEW=false
VITE_BASE_API=http://172.16.100.93: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
View File

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

7
.env.production Normal file
View 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
View File

@ -0,0 +1,23 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
env: {
node: true // 只需将该项设置为 true 即可
},
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': 'off',
"no-unused-vars":"off"
}
}

26
.gitignore vendored Normal file
View 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
View 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
View 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 给作者鼓励一下。

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
build/icons/lumenim.icns Normal file

Binary file not shown.

BIN
build/icons/lumenim.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
build/icons/lumenim.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

92
electron/main.js Normal file
View File

@ -0,0 +1,92 @@
// 控制应用生命周期和创建原生浏览器窗口的模组
const { app, BrowserWindow, ipcMain, Menu, MenuItem } = require('electron')
const path = require('path')
const { shell } = require('electron')
const NODE_ENV = process.env.NODE_ENV
function loadHtmlUrl() {
return NODE_ENV === 'development'
? `http://localhost:${process.env.PROT}`
: `file://${path.join(__dirname, '../dist/index.html')}`
}
function createWindow() {
// 创建浏览器窗口
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
frame: false,
titleBarStyle: 'hidden',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
},
})
// 加载 index.html
win.loadURL(loadHtmlUrl())
// 打开开发工具
if (NODE_ENV === 'development') {
win.webContents.openDevTools()
}
// 进入全屏模式
win.on('enter-full-screen', function () {
win.webContents.send('full-screen', 'enter')
})
// 退出全屏模式
win.on('leave-full-screen', function () {
win.webContents.send('full-screen', 'leave')
})
ipcMain.on('get-full-screen', (e, data) => {
e.returnValue = win.isFullScreen()
})
ipcMain.on('app-info', (e, data) => {
e.returnValue = {
platform: process.platform,
version: app.getVersion(),
appPath: app.getAppPath(),
}
})
}
// 这段程序将会在 Electron 结束初始化
// 和创建浏览器窗口的时候调用
// 部分 API 在 ready 事件触发后才能使用。
app.whenReady().then(() => {
createWindow()
app.on('activate', function () {
// 通常在 macOS 上,当点击 dock 中的应用程序图标时,如果没有其他
// 打开的窗口,那么程序会重新创建一个窗口。
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 除了 macOS 外,当所有窗口都被关闭的时候退出程序。 因此,通常对程序和它们在
// 任务栏上的图标来说,应当保持活跃状态,直到用户使用 Cmd + Q 退出。
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
// 在这个文件中,你可以包含应用程序剩余的所有部分的代码,
// 也可以拆分成几个文件,然后用 require 导入。
ipcMain.on('ipc:set-badge', async (event, num) => {
if (process.platform === 'darwin') {
app.dock.setBadge(num > 99 ? '99+' : num)
}
})
ipcMain.on('ipc:open-link', async (event, link) => {
// Open a link in the default browser
shell.openExternal(link)
})

46
electron/preload.js Normal file
View File

@ -0,0 +1,46 @@
const { contextBridge, ipcRenderer } = require('electron')
// 暴露方法给渲染进程调用
contextBridge.exposeInMainWorld('electron', {
// 设置消息未读数
setBadge: num => {
ipcRenderer.send('ipc:set-badge', num == 0 ? '' : `${num}`)
},
// 获取窗口全屏状态
getFullScreenStatus: () => {
return ipcRenderer.sendSync('get-full-screen', '')
},
// 系统信息
getAppPlatform: () => {
return ipcRenderer.sendSync('app-info', '')
},
openLink: link => {
ipcRenderer.send('ipc:open-link', link)
},
})
// 窗口变化事件
ipcRenderer.on('full-screen', function (event, value) {
// isFullScreenStatus = value == 'enter'
document.dispatchEvent(
new CustomEvent('full-screen-event', { detail: value })
)
})
// 触发自定义事件
// document.dispatchEvent(new CustomEvent('myTestEvent', {num: i}))
// document.addEventListener('myTestEvent', e => {console.log(e)})
// 所有Node.js API都可以在预加载过程中使用。
// 它拥有与Chrome扩展一样的沙盒。
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
const element = document.getElementById(selector)
if (element) element.innerText = text
}
for (const dependency of ['chrome', 'node', 'electron']) {
replaceText(`${dependency}-version`, process.versions[dependency])
}
})

9
env.d.ts vendored Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

101
src/App.vue Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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')
}

View 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;
}
}
}

View 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);
}
}

View 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);
}

View 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%;
}
}

View 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
View 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;
}
}

View 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;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

1
src/assets/image/md.svg Normal file
View 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

View 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

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 107 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 205 KiB

BIN
src/assets/music.mp3 Normal file

Binary file not shown.

View 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>

View 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 Bufferblob 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,5 @@
import DialogApi from './DialogApi.vue'
import MessageApi from './MessageApi.vue'
import NotificationApi from './NotificationApi.vue'
export { DialogApi, MessageApi, NotificationApi }

View 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>

View 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>

View 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>

View 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>

View 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" />
&nbsp;开始录音
</n-button>
<n-button v-show="status == 1" type="primary" round @click="onStop">
<n-icon :component="Voice" />
&nbsp;结束录音
</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>

View 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="&nbsp;请输入选项内容" 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>

View 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

View 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

View 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'
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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> &nbsp;{{ 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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))
}
}

View File

@ -0,0 +1,23 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
const { showUserInfoModal } = useInject()
defineProps({
extra: Object,
data: Object
})
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span>取消了全员禁言</span>
</div>
</div>
</template>

View 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>

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