huangqinghua 10 сар өмнө
commit
5442460d23
100 өөрчлөгдсөн 5550 нэмэгдсэн , 0 устгасан
  1. 9 0
      .editorconfig
  2. 1 0
      .env.app
  3. 1 0
      .env.web
  4. 4 0
      .eslintignore
  5. 18 0
      .eslintrc.cjs
  6. 6 0
      .gitignore
  7. 2 0
      .npmrc
  8. 6 0
      .prettierignore
  9. 4 0
      .prettierrc.yaml
  10. 29 0
      README.md
  11. 12 0
      build/entitlements.mac.plist
  12. BIN
      build/icon.icns
  13. BIN
      build/icon.ico
  14. BIN
      build/icon.png
  15. BIN
      build/icons/1024x1024.png
  16. BIN
      build/icons/128x128.png
  17. BIN
      build/icons/16x16.png
  18. BIN
      build/icons/24x24.png
  19. BIN
      build/icons/256x256.png
  20. BIN
      build/icons/32x32.png
  21. BIN
      build/icons/48x48.png
  22. BIN
      build/icons/512x512.png
  23. BIN
      build/icons/64x64.png
  24. BIN
      build/icons/icon.icns
  25. BIN
      build/icons/icon.ico
  26. 3 0
      dev-app-update.yml
  27. 47 0
      electron-builder.yml
  28. 30 0
      electron.vite.config.ts
  29. 73 0
      package.json
  30. BIN
      resources/empty.ico
  31. BIN
      resources/empty.png
  32. BIN
      resources/icon.ico
  33. BIN
      resources/icon.png
  34. BIN
      resources/logo.png
  35. 343 0
      src/main/index.ts
  36. 46 0
      src/main/useCheckUpdate.ts
  37. 8 0
      src/preload/index.d.ts
  38. 22 0
      src/preload/index.ts
  39. 11 0
      src/renderer/index.html
  40. 27 0
      src/renderer/src/App.vue
  41. 79 0
      src/renderer/src/api/Auth.ts
  42. 85 0
      src/renderer/src/api/ChatApi.ts
  43. 47 0
      src/renderer/src/api/CollectApi.ts
  44. 49 0
      src/renderer/src/api/DeptApi.ts
  45. 147 0
      src/renderer/src/api/FetchRequest.ts
  46. 55 0
      src/renderer/src/api/FriendApi.ts
  47. 141 0
      src/renderer/src/api/GroupApi.ts
  48. 41 0
      src/renderer/src/api/GroupInviteApi.ts
  49. 34 0
      src/renderer/src/api/ImmunityApi.ts
  50. 32 0
      src/renderer/src/api/Login.ts
  51. 56 0
      src/renderer/src/api/MessageApi.ts
  52. 25 0
      src/renderer/src/api/SettingApi.ts
  53. 66 0
      src/renderer/src/api/UserApi.ts
  54. 256 0
      src/renderer/src/api/WsRequest.ts
  55. BIN
      src/renderer/src/assets/bg.png
  56. BIN
      src/renderer/src/assets/calling.mp3
  57. BIN
      src/renderer/src/assets/empty.ico
  58. 367 0
      src/renderer/src/assets/font/iconfont.css
  59. 0 0
      src/renderer/src/assets/font/iconfont.js
  60. 625 0
      src/renderer/src/assets/font/iconfont.json
  61. BIN
      src/renderer/src/assets/font/iconfont.ttf
  62. BIN
      src/renderer/src/assets/font/iconfont.woff
  63. BIN
      src/renderer/src/assets/font/iconfont.woff2
  64. BIN
      src/renderer/src/assets/icon.ico
  65. BIN
      src/renderer/src/assets/icon.png
  66. 34 0
      src/renderer/src/assets/icons.svg
  67. BIN
      src/renderer/src/assets/poster.gif
  68. 26 0
      src/renderer/src/assets/styles/g.css
  69. 18 0
      src/renderer/src/assets/styles/theme.less
  70. 146 0
      src/renderer/src/assets/styles/v-im.less
  71. 92 0
      src/renderer/src/components/AvatarUpload.vue
  72. 122 0
      src/renderer/src/components/ChatGroupInfo.vue
  73. 159 0
      src/renderer/src/components/ChatItem.vue
  74. 42 0
      src/renderer/src/components/ChatMessageUser.vue
  75. 123 0
      src/renderer/src/components/ChatsRadio.vue
  76. 102 0
      src/renderer/src/components/FileUpload.vue
  77. 176 0
      src/renderer/src/components/HistoryMessage.vue
  78. 117 0
      src/renderer/src/components/MessageForward.vue
  79. 92 0
      src/renderer/src/components/QuoteMessage.vue
  80. 110 0
      src/renderer/src/components/SearchFriend.vue
  81. 16 0
      src/renderer/src/components/UserNameTag.vue
  82. 103 0
      src/renderer/src/components/VimAvatar.vue
  83. 45 0
      src/renderer/src/components/VimFaces.vue
  84. 49 0
      src/renderer/src/components/VimTime.vue
  85. 71 0
      src/renderer/src/components/VimTop.vue
  86. 124 0
      src/renderer/src/components/add-friend-modal/AddFriendModal.vue
  87. 25 0
      src/renderer/src/components/add-friend-modal/index.ts
  88. 134 0
      src/renderer/src/components/at-menu/AtMenu.vue
  89. 64 0
      src/renderer/src/components/at-menu/index.ts
  90. 79 0
      src/renderer/src/components/forward/MessageForward.vue
  91. 24 0
      src/renderer/src/components/forward/index.ts
  92. 194 0
      src/renderer/src/components/group/add-user/AddGroupUser.vue
  93. 24 0
      src/renderer/src/components/group/add-user/index.ts
  94. 122 0
      src/renderer/src/components/group/announcement/AnnouncementModal.vue
  95. 26 0
      src/renderer/src/components/group/announcement/index.ts
  96. 113 0
      src/renderer/src/components/group/info/GroupModal.vue
  97. 26 0
      src/renderer/src/components/group/info/index.ts
  98. 46 0
      src/renderer/src/components/messages/MessageEvent.vue
  99. 73 0
      src/renderer/src/components/messages/MessageFile.vue
  100. 26 0
      src/renderer/src/components/messages/MessageImage.vue

+ 9 - 0
.editorconfig

@@ -0,0 +1,9 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true

+ 1 - 0
.env.app

@@ -0,0 +1 @@
+VITE_TYPE=APP

+ 1 - 0
.env.web

@@ -0,0 +1 @@
+VITE_TYPE=WEB

+ 4 - 0
.eslintignore

@@ -0,0 +1,4 @@
+node_modules
+dist
+out
+.gitignore

+ 18 - 0
.eslintrc.cjs

@@ -0,0 +1,18 @@
+/* eslint-env node */
+require('@rushstack/eslint-patch/modern-module-resolution')
+
+module.exports = {
+  extends: [
+    'eslint:recommended',
+    'plugin:vue/vue3-recommended',
+    '@electron-toolkit',
+    '@electron-toolkit/eslint-config-ts/eslint-recommended',
+    '@vue/eslint-config-typescript/recommended',
+    '@vue/eslint-config-prettier'
+  ],
+  rules: {
+    'vue/require-default-prop': 'off',
+    'vue/multi-word-component-names': 'off',
+    'linebreak-style': ['error', 'unix']
+  }
+}

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+node_modules
+dist
+out
+*.log*
+*.idea
+

+ 2 - 0
.npmrc

@@ -0,0 +1,2 @@
+ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
+ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/

+ 6 - 0
.prettierignore

@@ -0,0 +1,6 @@
+out
+dist
+pnpm-lock.yaml
+LICENSE.md
+tsconfig.json
+tsconfig.*.json

+ 4 - 0
.prettierrc.yaml

@@ -0,0 +1,4 @@
+singleQuote: true
+semi: false
+printWidth: 100
+trailingComma: none

+ 29 - 0
README.md

@@ -0,0 +1,29 @@
+
+# 版权说明:此软件仅供已购买的用户个人或公司使用,未经授权不得公开源码、源码转发他人、转卖源码、二次开发后售卖、非授权使用,违者需赔付作者人民币100万元。
+# v-im-pc-2023
+### 命令详解
+>1. 开发模式命令
+    ```npm run dev```
+>2. 打包命令web
+    ```npm run build```
+>3. 打包命令exe
+    ```npm run build:win```
+>4. 打包命令mac
+    ```npm run build:mac```
+>5. 打包命令linux
+    ```npm run build:linux```
+>6. 模拟正式环境运行命令
+    ```npm run start```
+
+### 使用说明请参照开源版本。
+
+1. v-im-pc是pc端,请使用webstorm打开进行开发。
+2. v-im-server提醒声音和表情请将doc下face和Message.mp3放到  profile: D:/ruoyi/uploadPath 下,D:/ruoyi/uploadPath 是可以自行改变的。
+3. 建议使用yarn安装依赖。
+4. v-im-server是服务端,java开发,直接 run  VimApplication.java 即可。
+5. 开发模式 npm run dev
+6. 打包web npm run build
+7. 打包exe npm run build:win
+8. 打包mac和linux 和 打包exe 一样配置,打包不同的平台需要在相应的平台下打包。
+9. 账号 admin/kunchong  ry/kunchong
+10. 后台管理界面直接使用ruoyi-vue3即可,请自行去git下载:https://github.com/yangzongzhuan/RuoYi-Vue3 vue2版本:https://gitee.com/y_project/RuoYi-Vue

+ 12 - 0
build/entitlements.mac.plist

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>com.apple.security.cs.allow-jit</key>
+    <true/>
+    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
+    <true/>
+    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
+    <true/>
+  </dict>
+</plist>

BIN
build/icon.icns


BIN
build/icon.ico


BIN
build/icon.png


BIN
build/icons/1024x1024.png


BIN
build/icons/128x128.png


BIN
build/icons/16x16.png


BIN
build/icons/24x24.png


BIN
build/icons/256x256.png


BIN
build/icons/32x32.png


BIN
build/icons/48x48.png


BIN
build/icons/512x512.png


BIN
build/icons/64x64.png


BIN
build/icons/icon.icns


BIN
build/icons/icon.ico


+ 3 - 0
dev-app-update.yml

@@ -0,0 +1,3 @@
+provider: generic
+url: https://v-im-oss.oss-cn-beijing.aliyuncs.com/auto-updates
+updaterCacheDirName: v-im-pc-2023-updater

+ 47 - 0
electron-builder.yml

@@ -0,0 +1,47 @@
+appId: com.v-im.app
+productName: V-IM
+directories:
+  buildResources: build
+files:
+  - '!**/.vscode/*'
+  - '!src/*'
+  - '!electron.vite.config.{js,ts,mjs,cjs}'
+  - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
+  - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
+  - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
+asarUnpack:
+  - resources/**
+win:
+  executableName: V-IM
+  icon: build/icons/icon.ico
+nsis:
+  artifactName: ${name}-${version}-setup.${ext}
+  shortcutName: ${productName}
+  uninstallDisplayName: ${productName}
+  createDesktopShortcut: always
+  installerIcon: build/icons/icon.ico
+  uninstallerIcon: build/icons/icon.ico
+  installerHeaderIcon: build/icons/icon.ico
+mac:
+  entitlementsInherit: build/entitlements.mac.plist
+  extendInfo:
+    - NSCameraUsageDescription: Application requests access to the device's camera.
+    - NSMicrophoneUsageDescription: Application requests access to the device's microphone.
+    - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
+    - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
+  notarize: false
+dmg:
+  artifactName: ${name}-${version}.${ext}
+linux:
+  target:
+    - AppImage
+    - snap
+    - deb
+  maintainer: electronjs.org
+  category: Utility
+appImage:
+  artifactName: ${name}-${version}.${ext}
+npmRebuild: false
+publish:
+  provider: generic
+  url: https://v-im-oss.oss-cn-beijing.aliyuncs.com/auto-updates

+ 30 - 0
electron.vite.config.ts

@@ -0,0 +1,30 @@
+import { resolve } from 'path'
+import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+  main: {
+    plugins: [externalizeDepsPlugin()]
+  },
+  preload: {
+    plugins: [externalizeDepsPlugin()]
+  },
+  renderer: {
+    resolve: {
+      alias: {
+        '@renderer': resolve('src/renderer/src')
+      }
+    },
+    plugins: [vue()],
+    css: {
+      preprocessorOptions: {
+        less: {
+          additionalData: `
+          @import "@renderer/assets/styles/theme.less";
+          @import "@renderer/assets/styles/v-im.less";
+          `
+        }
+      }
+    }
+  }
+})

+ 73 - 0
package.json

@@ -0,0 +1,73 @@
+{
+  "name": "V-IM",
+  "version": "2.7.1",
+  "description": "一款基于js的轻量级即时通讯软件",
+  "main": "./out/main/index.js",
+  "author": "乐天",
+  "homepage": "https://gitee.com/alyouge/V-IM",
+  "scripts": {
+    "format": "prettier --write .",
+    "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
+    "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
+    "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
+    "typecheck": "npm run typecheck:node && npm run typecheck:web",
+    "start": "electron-vite preview --mode app",
+    "dev": "electron-vite dev --mode app",
+    "build": "electron-vite build --mode web",
+    "postinstall": "electron-builder install-app-deps",
+    "build:win": "electron-vite build --mode app && electron-builder --win --config",
+    "build:mac": "electron-vite build --mode app && electron-builder --mac --config",
+    "build:linux": "electron-vite build --mode app && electron-builder --linux --config",
+    "electron:generate-icons": "electron-icon-builder --input=./resources/icon.png --output=build --flatten"
+  },
+  "dependencies": {
+    "@electron-toolkit/preload": "^2.0.0",
+    "@electron-toolkit/utils": "^2.0.0",
+    "@element-plus/icons-vue": "^2.1.0",
+    "axios": "^1.6.0",
+    "better-scroll": "^2.4.2",
+    "core-js": "^3.30.2",
+    "date-fns": "^2.30.0",
+    "electron-updater": "^6.1.1",
+    "element-plus": "^2.6.0",
+    "file-saver": "^2.0.5",
+    "image-conversion": "^2.1.1",
+    "js-web-screen-shot": "^1.9.9-rc.18",
+    "jsencrypt": "3.2.1",
+    "konva": "^9.0.2",
+    "peerjs": "^1.4.7",
+    "pinia": "^2.1.1",
+    "pinia-plugin-persist": "^1.0.0",
+    "pinyin-pro": "^3.15.1",
+    "shortcuts": "^2.0.3",
+    "uuid": "^9.0.1",
+    "vue": "^3.3.4",
+    "vue-class-component": "^8.0.0-0",
+    "vue-clipboard3": "^2.0.0",
+    "vue-router": "^4.2.0",
+    "vue3-image-preview": "^0.2.7",
+    "vue3-menus": "^1.1.2"
+  },
+  "devDependencies": {
+    "@electron-toolkit/eslint-config": "^1.0.1",
+    "@electron-toolkit/eslint-config-ts": "^1.0.0",
+    "@electron-toolkit/tsconfig": "^1.0.1",
+    "@rushstack/eslint-patch": "^1.3.3",
+    "@types/node": "^18.17.5",
+    "@vitejs/plugin-vue": "^5.0.4",
+    "@vue/eslint-config-prettier": "^8.0.0",
+    "@vue/eslint-config-typescript": "^11.0.3",
+    "electron": "^30.0.0",
+    "electron-builder": "^24.13.3",
+    "electron-icon-builder": "^2.0.1",
+    "electron-vite": "^2.1.0",
+    "eslint": "^8.47.0",
+    "eslint-plugin-vue": "^9.17.0",
+    "less": "^4.2.0",
+    "prettier": "^3.0.2",
+    "typescript": "^5.1.6",
+    "vite": "^5.0.5",
+    "vue": "^3.3.4",
+    "vue-tsc": "^1.8.8"
+  }
+}

BIN
resources/empty.ico


BIN
resources/empty.png


BIN
resources/icon.ico


BIN
resources/icon.png


BIN
resources/logo.png


+ 343 - 0
src/main/index.ts

@@ -0,0 +1,343 @@
+import * as electron from 'electron'
+import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, Notification, screen, shell, Tray } from 'electron'
+import path, { join } from 'path'
+import { electronApp, is, optimizer } from '@electron-toolkit/utils'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+import { useCheckUpdate } from './useCheckUpdate'
+import vimConfig from '/src/renderer/src/config/VimConfig'
+
+//刷新托盘定时器
+let flashIconTimer: NodeJS.Timeout | null = null
+let cutWindow: BrowserWindow | null = null
+const iconPath =
+  process.platform === 'win32' ? '../../resources/icon.ico' : '../../resources/icon.png'
+const emptyIconPath =
+  process.platform === 'win32' ? '../../resources/empty.ico' : '../../resources/empty.png'
+let appIcon: Electron.Tray | null = null
+
+function createWindow(): void {
+  // Create the browser window.
+  const mainWindow = new BrowserWindow({
+    show: false,
+    autoHideMenuBar: true,
+    webPreferences: {
+      contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
+      webSecurity: true,
+      nodeIntegration: true, // 解决require is not defined问题
+      webviewTag: true, // 解决webview无法显示问题
+      preload: join(__dirname, '../preload/index.js'),
+      sandbox: false
+    },
+    useContentSize: true,
+    width: 1000,
+    height: 600,
+    frame: false
+  })
+
+  mainWindow.on('ready-to-show', () => {
+    mainWindow.show()
+    useCheckUpdate(app)
+  })
+
+  // 处理窗口失去焦点事件
+  mainWindow.on('blur', () => {
+    mainWindow.webContents.send('BLUR', true)
+  })
+
+  // 处理窗口获得焦点事件
+  mainWindow.on('focus', () => {
+    mainWindow.webContents.send('FOCUS', false)
+  })
+
+  /**
+   * 系统休眠
+   */
+  electron.powerMonitor.on('suspend', () => {
+    mainWindow.webContents.send('SLEEP')
+  })
+
+  /**
+   * 系统唤醒
+   */
+  electron.powerMonitor.on('resume', () => {
+    mainWindow.webContents.send('RESUME')
+  })
+
+  mainWindow.webContents.setWindowOpenHandler((details) => {
+    shell.openExternal(details.url).then()
+    return { action: 'deny' }
+  })
+
+  // HMR for renderer base on electron-vite cli.
+  // Load the remote URL for development or the local html file for production.
+  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
+    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']).then()
+  } else {
+    mainWindow.loadFile(join(__dirname, '../renderer/index.html')).then()
+  }
+
+  ipcMain.on('min', () => {
+    mainWindow.minimize()
+  })
+
+  ipcMain.on('openURL', (e: Electron.IpcMainEvent, url: string) => {
+    e.preventDefault()
+    shell.openExternal(url).then()
+  })
+
+  ipcMain.on('max', () => {
+    if (mainWindow.isMaximized()) {
+      mainWindow.unmaximize()
+    } else {
+      mainWindow.maximize()
+    }
+  })
+
+  // 只是隐藏任务栏
+  ipcMain.on('close', () => {
+    hideMain(mainWindow)
+  })
+
+  // 闪烁任务栏
+  ipcMain.on('flashFrame', () => {
+    //没有聚焦
+    if (!mainWindow.isFocused()) {
+      mainWindow.flashFrame(true)
+    }
+  })
+
+  /**
+   * 发生系统通知
+   * @param content 通知内容
+   * @param url 点击通知跳转的url
+   */
+  ipcMain.on(
+    'NOTIFICATION',
+    (e: Electron.IpcMainEvent, content: string, url: string | undefined) => {
+      e.preventDefault()
+      if (!mainWindow.isFocused()) {
+        new Notification({
+          title: vimConfig.name,
+          body: content,
+          timeoutType: 'never'
+        })
+          .on('click', (event) => {
+            event.preventDefault()
+            showMain(mainWindow)
+            if (url) {
+              shell.openExternal(url).then()
+            }
+          })
+          .show()
+      }
+    }
+  )
+
+  appIcon = createTray(mainWindow, iconPath)
+
+  // 闪烁任务栏
+  ipcMain.on('flashIcon', () => {
+    if (!mainWindow.isVisible()) {
+      clearFlashIconTimer()
+      let count = 0
+      flashIconTimer = setInterval(() => {
+        count++
+        if (appIcon) {
+          if (count % 2 === 0) {
+            appIcon.setImage(path.join(__dirname, emptyIconPath))
+          } else {
+            appIcon.setImage(path.join(__dirname, iconPath))
+          }
+        }
+      }, 500)
+    }
+  })
+
+  ipcMain.on('clearFlashIcon', () => {
+    clearFlashIconTimer()
+    if (appIcon) {
+      appIcon.setImage(path.join(__dirname, iconPath))
+    }
+  })
+
+  // 获取设备窗口信息
+  ipcMain.handle('getSource', async () => {
+    const sources = await desktopCapturer.getSources({
+      types: ['screen'],
+      thumbnailSize: getSize()
+    })
+    //当前的屏幕id
+    const { id } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
+    //多屏情况下,只截图当前的屏幕
+    return sources.find((source) => source.display_id === id + '') ?? sources[0]
+  })
+
+  /**
+   * 点击截屏弹出截屏窗口
+   */
+  ipcMain.on('OPEN_CUT_SCREEN', async (event, args) => {
+    event.preventDefault()
+    closeCutWindow()
+    if (args) {
+      mainWindow.hide()
+    }
+    await createCutWindow()
+    if (cutWindow) {
+      cutWindow.show()
+    }
+  })
+
+  /**
+   * 截屏事件
+   */
+  ipcMain.on('CUT_SCREEN', async (e, cutInfo) => {
+    e.preventDefault()
+    closeCutWindow()
+    mainWindow.webContents.send('GET_CUT_INFO', cutInfo)
+    mainWindow.show()
+  })
+
+  /**
+   * 关闭截屏窗口
+   */
+  ipcMain.on('CLOSE_CUT_SCREEN', async () => {
+    closeCutWindow()
+    mainWindow.show()
+  })
+
+}
+
+/**
+ * 隐藏窗口,隐藏任务栏
+ */
+function hideMain(win: BrowserWindow) {
+  win.setSkipTaskbar(true)
+  win.hide()
+}
+
+/**
+ * 清除图片闪烁的定时器
+ */
+function clearFlashIconTimer() {
+  if (flashIconTimer) {
+    clearInterval(flashIconTimer)
+    if (appIcon) {
+      appIcon.setImage(path.join(__dirname, iconPath))
+    }
+  }
+}
+
+/**
+ * 获取截屏的size
+ */
+function getSize() {
+  const { size, scaleFactor } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
+  return {
+    width: Math.floor(size.width * scaleFactor),
+    height: Math.floor(size.height * scaleFactor)
+  }
+}
+
+/**
+ * 创建一个截屏窗口
+ */
+async function createCutWindow() {
+  cutWindow = new BrowserWindow({
+    useContentSize: true,
+    autoHideMenuBar: true,
+    movable: false,
+    frame: false,
+    resizable: false,
+    hasShadow: false,
+    transparent: true,
+    fullscreen: true,
+    simpleFullscreen: true,
+    alwaysOnTop: false,
+    webPreferences: {
+      contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
+      webSecurity: false,
+      nodeIntegration: true, // 解决require is not defined问题
+      webviewTag: true, // 解决webview无法显示问题
+      preload: join(__dirname, '../preload/index.js'),
+      sandbox: false
+    }
+  })
+  const cutPage = process.platform === 'win32' ? 'cutWin32' : 'cutLinux'
+  if (process.env['ELECTRON_RENDERER_URL']) {
+    await cutWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/${cutPage}`)
+    if (!process.env.IS_TEST) cutWindow.webContents.openDevTools()
+  } else {
+    await cutWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash: cutPage })
+  }
+  cutWindow.maximize()
+  cutWindow.setFullScreen(true)
+}
+
+function closeCutWindow() {
+  cutWindow && cutWindow.close()
+  cutWindow = null
+}
+
+app.whenReady().then(() => {
+  electronApp.setAppUserModelId('com.electron')
+  app.on('browser-window-created', (_, window) => {
+    optimizer.watchWindowShortcuts(window)
+  })
+
+  createWindow()
+
+  app.on('activate', () => {
+    if (BrowserWindow.getAllWindows().length === 0) createWindow()
+  })
+})
+
+app.on('window-all-closed', () => {
+  if (process.platform !== 'darwin') {
+    app.quit()
+  }
+})
+
+app.on('browser-window-focus', () => {
+  clearFlashIconTimer()
+})
+
+/**
+ * 创建托盘图标
+ * @param win
+ * @param iconPath
+ */
+function createTray(win: BrowserWindow, iconPath: string) {
+  // 托盘
+  const appIcon = new Tray(path.join(__dirname, iconPath))
+  const contextMenu = Menu.buildFromTemplate([
+    {
+      label: '显示',
+      click: () => {
+        showMain(win)
+      }
+    },
+    {
+      label: '退出',
+      click: () => {
+        app.quit()
+      }
+    }
+  ])
+  appIcon.setToolTip(vimConfig.name)
+  appIcon.setContextMenu(contextMenu)
+  appIcon.on('click', () => {
+    showMain(win)
+  })
+
+  return appIcon
+}
+
+/**
+ * 展示窗口,打开任务栏
+ */
+function showMain(win: BrowserWindow) {
+  win.setSkipTaskbar(false)
+  win.show()
+  clearFlashIconTimer()
+}

+ 46 - 0
src/main/useCheckUpdate.ts

@@ -0,0 +1,46 @@
+import { autoUpdater } from 'electron-updater'
+import { Notification, dialog } from 'electron'
+/**
+ * 检测更新
+ * @param app Electron.App
+ */
+const useCheckUpdate = (app: Electron.App) => {
+  autoUpdater.autoDownload = false
+
+  //检测更新
+  autoUpdater.checkForUpdates().then()
+
+  //监听error事件
+  autoUpdater.on('error', (err) => {
+    console.log(err)
+  })
+
+  //监听update-available事件,发现有新版本时触发
+  autoUpdater.on('update-available', () => {
+    dialog
+      .showMessageBox({
+        type: 'info',
+        title: '更新提醒',
+        message: '发现新版本,下载完成后将自动安装更新',
+        buttons: ['确定']
+      })
+      .then((buttonIndex) => {
+        if (buttonIndex.response == 0) {
+          new Notification({
+            title: 'V-IM',
+            body: '正在更新中,请稍后...'
+          }).show()
+          autoUpdater.downloadUpdate().then()
+        }
+      })
+  })
+
+  //默认会自动下载新版本,如果不想自动下载,设置autoUpdater.autoDownload = false
+  //监听update-downloaded事件,新版本下载完成时触发
+  autoUpdater.on('update-downloaded', () => {
+    autoUpdater.quitAndInstall()
+    app.quit()
+  })
+}
+
+export { useCheckUpdate }

+ 8 - 0
src/preload/index.d.ts

@@ -0,0 +1,8 @@
+import { ElectronAPI } from '@electron-toolkit/preload'
+
+declare global {
+  interface Window {
+    electron: ElectronAPI
+    api: unknown
+  }
+}

+ 22 - 0
src/preload/index.ts

@@ -0,0 +1,22 @@
+import { contextBridge } from 'electron'
+import { electronAPI } from '@electron-toolkit/preload'
+
+// Custom APIs for renderer
+const api = {}
+
+// Use `contextBridge` APIs to expose Electron APIs to
+// renderer only if context isolation is enabled, otherwise
+// just add to the DOM global.
+if (process.contextIsolated) {
+  try {
+    contextBridge.exposeInMainWorld('electron', electronAPI)
+    contextBridge.exposeInMainWorld('api', api)
+  } catch (error) {
+    console.error(error)
+  }
+} else {
+  // @ts-ignore (define in dts)
+  window.electron = electronAPI
+  // @ts-ignore (define in dts)
+  window.api = api
+}

+ 11 - 0
src/renderer/index.html

@@ -0,0 +1,11 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="UTF-8" />
+    <title>V-IM(乐聊)</title>
+  </head>
+  <body>
+    <div id="v-im-app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 27 - 0
src/renderer/src/App.vue

@@ -0,0 +1,27 @@
+<template>
+  <router-view></router-view>
+</template>
+
+<script setup lang="ts">
+
+</script>
+<style lang="less">
+html {
+  font-size: 16px;
+  height: 100%;
+}
+body {
+  height: 100%;
+  margin: 0;
+  padding: 0;
+}
+#v-im-app {
+  width: 100%;
+  height: 100%;
+  margin: 0 auto;
+}
+//引用
+#quote{
+  position: absolute;
+}
+</style>

+ 79 - 0
src/renderer/src/api/Auth.ts

@@ -0,0 +1,79 @@
+import vimConfig from '@renderer/config/VimConfig'
+import FetchRequest from '@renderer/api/FetchRequest'
+import { useWsStore } from '@renderer/store/WsStore'
+import { useChatStore } from '@renderer/store/chatStore'
+import { logout } from './Login'
+import router from '@renderer/router'
+import { ElMessage } from 'element-plus'
+
+class Auth {
+  static getToken = (): string => {
+    return localStorage.getItem('access_token') ?? ''
+  }
+
+  static setToken = (token: string): void => {
+    localStorage.setItem('access_token', token)
+  }
+
+  static setRefreshToken = (token: string): void => {
+    localStorage.setItem('refresh_token', token)
+  }
+
+  static getRefreshToken = (): string => {
+    return localStorage.getItem('refresh_token') ?? ''
+  }
+
+  static clearToken = (): void => {
+    localStorage.removeItem('access_token')
+    localStorage.removeItem('refresh_token')
+  }
+
+  static setIp = (ip: string): void => {
+    localStorage.setItem('ip', ip)
+  }
+
+  static getIp = (): string => {
+    return vimConfig.host
+  }
+
+  static isLogin = () => {
+    return new Promise((resolve, reject) => {
+      const header: HeadersInit = {
+        Accept: 'application/json',
+        'Content-Type': 'application/json',
+        Authorization: 'Bearer ' + Auth.getToken()
+      }
+      const config: RequestInit = {
+        method: 'GET',
+        mode: 'cors',
+        headers: header
+      }
+      fetch(`${FetchRequest.getHost()}/api/sys/users/my`, config)
+        .then((res) => {
+          return res.json()
+        })
+        .then((res) => {
+          if (res.code === 200) {
+            localStorage.setItem('userId', res.data.id)
+            resolve(true)
+          } else {
+            reject(false)
+          }
+        })
+        .catch(() => {
+          reject(false)
+        })
+    })
+  }
+
+  static logout = () => {
+    logout().finally(() => {
+      useChatStore().clearMessage()
+      useWsStore().close()
+      router.push('/').catch(() => {
+        ElMessage.error('无法跳转到登录界面')
+      })
+    })
+  }
+}
+export default Auth

+ 85 - 0
src/renderer/src/api/ChatApi.ts

@@ -0,0 +1,85 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import Chat from '@renderer/mode/Chat'
+import AjaxResult from '@renderer/mode/AjaxResult'
+
+class ChatApi {
+  static url = '/api/sys/chat'
+
+  /**
+   * 获取当前用户的非置顶聊天列表
+   */
+  static list(): Promise<AjaxResult<Chat[]>> {
+    return FetchRequest.get(`${this.url}/list`, true)
+  }
+
+  /**
+   * 获取当前用户的置顶聊天列表
+   */
+  static topList(): Promise<AjaxResult<Chat[]>> {
+    return FetchRequest.get(`${this.url}/topList`, true)
+  }
+
+  /**
+   * 新增聊天
+   * @param chat chat
+   */
+  static add(chat: Chat): Promise<AjaxResult<boolean>> {
+    return FetchRequest.post(this.url, JSON.stringify(chat), true)
+  }
+
+  /**
+   * 跟新聊天
+   * @param chat chat
+   */
+  static update(chat: Chat): Promise<AjaxResult<boolean>> {
+    const chatTemp = JSON.parse(JSON.stringify(chat))
+    chatTemp.unreadCount = 0
+    return FetchRequest.put(this.url, JSON.stringify(chatTemp), true)
+  }
+
+  /**
+   * 批量更新聊天
+   * @param chatList chatList
+   */
+  static batch(chatList: Array<Chat>): Promise<AjaxResult<boolean>> {
+    const chatListTemp = JSON.parse(JSON.stringify(chatList))
+    chatListTemp.forEach((chat) => {
+      chat.unreadCount = 0
+    })
+    return FetchRequest.put(`${this.url}/batch`, JSON.stringify(chatListTemp), true)
+  }
+
+  /**
+   * 移动聊天室位置
+   * @param chatId chatId
+   */
+  static move(chatId: string): void {
+    FetchRequest.get(`${this.url}/move?chatId=${chatId}`, true)
+  }
+
+  /**
+   *  置顶聊天
+   *  @param chatId chatId
+   */
+  static top(chatId: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.get(`${this.url}/top?chatId=${chatId}`, true)
+  }
+
+  /**
+   *  取消置顶聊天
+   *  @param chatId 收藏id
+   */
+  static cancelTop(chatId: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.get(`${this.url}/cancelTop?chatId=${chatId}`, true)
+  }
+
+  /**
+   * 删除聊天
+   * @param chatId 聊天id
+   */
+  static delete(chatId: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.del(`${this.url}/${chatId}`, '', true)
+  }
+}
+
+export default ChatApi

+ 47 - 0
src/renderer/src/api/CollectApi.ts

@@ -0,0 +1,47 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import Collect from '@renderer/mode/Collect'
+import AjaxResult from '@renderer/mode/AjaxResult'
+
+class CollectApi {
+  static url = '/api/sys/collects'
+
+  /**
+   * 获取当前用户所有收藏
+   */
+  static list(type: string): Promise<AjaxResult<Collect[]>> {
+    return FetchRequest.get(`${this.url}?type=${type}`, true)
+  }
+
+  /**
+   * 获取收藏
+   */
+  static get(id: string): Promise<AjaxResult<Collect>> {
+    return FetchRequest.get(`${this.url}/${id}`, true)
+  }
+
+  /**
+   * 保存收藏
+   * @param collect 收藏
+   */
+  static save(collect: Collect): Promise<AjaxResult<boolean>> {
+    return FetchRequest.post(this.url, JSON.stringify(collect), true)
+  }
+
+  /**
+   * 修改收藏
+   *  @param collect 收藏
+   */
+  static update(collect: Collect): Promise<AjaxResult<boolean>> {
+    return FetchRequest.put(this.url, JSON.stringify(collect), true)
+  }
+
+  /**
+   *  删除收藏
+   *  @param id 收藏id
+   */
+  static delete(id: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.del(`${this.url}/${id}`, '', true)
+  }
+}
+
+export default CollectApi

+ 49 - 0
src/renderer/src/api/DeptApi.ts

@@ -0,0 +1,49 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import type Dept from '@renderer/mode/Dept'
+import AjaxResult from '@renderer/mode/AjaxResult'
+import User from '@renderer/mode/User'
+import TreeNode from '@renderer/mode/TreeNode'
+
+class DeptApi {
+  static url = '/api/sys/depts'
+
+  /**
+   * 获取所有上级部门
+   * @param deptId 部门ID
+   */
+  static parent(deptId: string): Promise<AjaxResult<Dept[]>> {
+    return FetchRequest.get(this.url + '/parent?deptId=' + deptId, true)
+  }
+
+  /**
+   * 获取所有部门
+   */
+  static list(): Promise<AjaxResult<TreeNode[]>> {
+    return FetchRequest.get(this.url, true)
+  }
+
+  /**
+   * 获取部门
+   * @param id 部门ID
+   */
+  static get(id: string): Promise<AjaxResult<Dept>> {
+    return FetchRequest.get(this.url + '/' + id, true)
+  }
+
+  /**
+   * 获取部门用户
+   * @param deptId 部门ID
+   */
+  static users(deptId: string): Promise<AjaxResult<User[]>> {
+    return FetchRequest.get(this.url + '/' + deptId + '/users', true)
+  }
+
+  /**
+   * 获取部门人数
+   */
+  static count(): Promise<AjaxResult<number>> {
+    return FetchRequest.get(this.url + '/count', true)
+  }
+}
+
+export default DeptApi

+ 147 - 0
src/renderer/src/api/FetchRequest.ts

@@ -0,0 +1,147 @@
+import Auth from '@renderer/api/Auth'
+import { ElMessage } from 'element-plus'
+import vimConfig from '@renderer/config/VimConfig'
+import VimConfig from '@renderer/config/VimConfig'
+
+/**
+ * 请求类,支持无感刷新token
+ * @author 乐天
+ */
+class FetchRequest {
+  isRefreshing: boolean
+  private static instance: FetchRequest
+
+  private constructor() {
+    this.isRefreshing = false
+  }
+
+  /**
+   * 单例构造方法,构造一个广为人知的接口,供用户对该类进行实例化
+   * @returns {FetchRequest}
+   */
+  static getInstance() {
+    if (!this.instance) {
+      this.instance = new FetchRequest()
+    }
+    return this.instance
+  }
+
+  /**
+   * 请求方法
+   * @param url 请求路径
+   * @param params 参数
+   * @param method 方法
+   * @param isNeedToken 是否需要token
+   */
+  request = (url: string, params: string, method: string, isNeedToken = false) => {
+    const header: HeadersInit = {
+      Accept: 'application/json',
+      'Content-Type': 'application/json'
+    }
+
+    const token = Auth.getToken()
+    if (isNeedToken && token) {
+      header.Authorization = 'Bearer ' + token
+    }
+
+    const config: RequestInit = {
+      method: method,
+      mode: 'cors',
+      headers: header
+    }
+
+    if (method !== 'GET') {
+      config.body = params
+    }
+
+    return fetch(this.getHost() + url, config).then((response) => {
+      return this.check(response)
+    })
+  }
+
+  /**
+   * upload请求方法
+   */
+  upload = (file: File) => {
+    const token = Auth.getToken()
+    const header: HeadersInit = {
+      'Access-Control-Allow-Origin': '*',
+      Authorization: 'Bearer ' + token
+    }
+    const formData = new FormData()
+    formData.append('file', file)
+    const config: RequestInit = {
+      method: 'POST',
+      mode: 'cors',
+      headers: header,
+      body: formData
+    }
+
+    return fetch(`${this.getHost()}/${VimConfig.uploadType}/upload`, config).then((response) => {
+      return this.check(response)
+    })
+  }
+
+  /**
+   * 检查请求返回值,如果token失效,执行刷新方法
+   * @param response 请求响应数据
+   */
+  check = (response: Response) => {
+    //token 失效
+    if (response.status === 200) {
+      return response
+        .json()
+        .then((res) => {
+          if (res.code === 401) {
+            Auth.logout()
+            return Promise.reject(res)
+          } else if (res.code !== 200) {
+            ElMessage.error(res.msg)
+            return Promise.reject(res)
+          } else {
+            return Promise.resolve(res)
+          }
+        })
+        .catch((err) => {
+          return Promise.reject(err)
+        })
+    } else {
+      ElMessage.error('请求出错,状态码:' + response.status)
+      return Promise.reject('请求出错')
+    }
+  }
+
+  /**
+   * 获取有效的ip
+   */
+  getEffectiveIp = (): string => {
+    return Auth.getIp().length > 0 ? Auth.getIp() : VimConfig.host
+  }
+
+  getHost = (): string => {
+    return `${vimConfig.httProtocol}://${Auth.getIp()}:${vimConfig.httPort}`
+  }
+
+  // 有些 api 并不需要用户授权使用,则无需携带 access_token;默认不携带,需要传则设置第三个参数为 true
+  get = (url: string, isNeedToken = false) => {
+    return this.request(url, '', 'GET', isNeedToken)
+  }
+
+  post = (url: string, params: string, isNeedToken = false) => {
+    return this.request(url, params, 'POST', isNeedToken)
+  }
+
+  put = (url: string, params: string, isNeedToken = false) => {
+    return this.request(url, params, 'PUT', isNeedToken)
+  }
+
+  del = (url: string, params: string, isNeedToken = false) => {
+    return this.request(url, params, 'DELETE', isNeedToken)
+  }
+
+  patch = (url: string, params: string, isNeedToken = false) => {
+    return this.request(url, params, 'PATCH', isNeedToken)
+  }
+}
+
+export default FetchRequest.getInstance()

+ 55 - 0
src/renderer/src/api/FriendApi.ts

@@ -0,0 +1,55 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import AjaxResult from '@renderer/mode/AjaxResult'
+import type User from '@renderer/mode/User'
+import Friend from '@renderer/mode/Friend'
+
+class FriendApi {
+  static url = '/api/sys/friends'
+
+  /**
+   * 获取用户的所有好友
+   */
+  static friends(): Promise<AjaxResult<User[]>> {
+    return FetchRequest.get(this.url, true)
+  }
+
+  /**
+   * 获取用户的待验证好友
+   */
+  static waitCheckList(): Promise<AjaxResult<Friend[]>> {
+    return FetchRequest.get(`${this.url}/validateList`, true)
+  }
+
+  /**
+   * 添加好友
+   * @param friend 好友
+   */
+  static add(friend: Friend): Promise<AjaxResult<boolean>> {
+    return FetchRequest.post(`${this.url}/add`, JSON.stringify(friend), true)
+  }
+
+  /**
+   * 同意加好友
+   * @param friendId 好友ID
+   */
+  static agree(friendId: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.post(`${this.url}/agree`, friendId, true)
+  }
+
+  /**
+   * 删除好友
+   * @param friendId 好友ID
+   */
+  static delete(friendId: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.del(`${this.url}/delete`, friendId, true)
+  }
+
+  /**
+   * 判断是否好友
+   */
+  static isFriend(friendId: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.get(`${this.url}/isFriend?friendId=${friendId}`, true)
+  }
+}
+
+export default FriendApi

+ 141 - 0
src/renderer/src/api/GroupApi.ts

@@ -0,0 +1,141 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import AjaxResult from '@renderer/mode/AjaxResult'
+import Group from '@renderer/mode/Group'
+import User from '@renderer/mode/User'
+
+class GroupApi {
+  //基础url
+  static url = '/api/sys/groups'
+
+  /**
+   * 添加群组
+   * @param name 群名称
+   * @param avatar 群头像
+   * @param openInvite  是否开放邀请
+   * @param inviteCheck 加群是否需要审核
+   * @param prohibition 禁言
+   * @param prohibitFriend 是否禁加好友
+   * @param announcement 群公告
+   */
+  static save(
+    name: string,
+    avatar: string,
+    openInvite: string,
+    inviteCheck: string,
+    prohibition: string,
+    prohibitFriend: string,
+    announcement: string
+  ): Promise<AjaxResult<Group>> {
+    const data = {
+      name: name,
+      avatar: avatar,
+      openInvite: openInvite,
+      inviteCheck: inviteCheck,
+      prohibition: prohibition,
+      prohibitFriend: prohibitFriend,
+      announcement: announcement
+    }
+    return FetchRequest.post(this.url, JSON.stringify(data), true)
+  }
+
+  /**
+   * 更新群组
+   * @param id 群id
+   * @param name 群名称
+   * @param avatar 群头像
+   * @param inviteCheck 加群是否需要审核
+   * @param prohibition 禁言
+   * @param prohibitFriend 是否禁加好友
+   * @param announcement 群公告
+   * @param openInvite  是否开放邀请
+   */
+  static update(
+    id: string,
+    name: string,
+    avatar: string,
+    openInvite: string,
+    inviteCheck: string,
+    prohibition: string,
+    prohibitFriend: string,
+    announcement: string
+  ): Promise<AjaxResult<boolean>> {
+    const data = {
+      name: name,
+      avatar: avatar,
+      openInvite: openInvite,
+      inviteCheck: inviteCheck,
+      prohibition: prohibition,
+      prohibitFriend: prohibitFriend,
+      announcement: announcement
+    }
+    return FetchRequest.patch(`${this.url}/${id}`, JSON.stringify(data), true)
+  }
+
+  /**
+   * 获取一个群的信息
+   * @param id 群id
+   */
+  static get(id: string): Promise<AjaxResult<Group>> {
+    return FetchRequest.get(`${this.url}/${id}`, true)
+  }
+
+  /**
+   * 查询当前用户的群组
+   */
+  static list(): Promise<AjaxResult<Group[]>> {
+    return FetchRequest.get(this.url, true)
+  }
+
+  /**
+   * 获取一个群的所有用户
+   * @param id 群id
+   */
+  static users(id: string): Promise<AjaxResult<User[]>> {
+    return FetchRequest.get(`${this.url}/${id}/users`, true)
+  }
+
+  /**
+   * 删除群
+   * @param id 用户ID
+   */
+  static delete(id: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.del(`${this.url}/${id}`, '', true)
+  }
+
+  /**
+   * 退群
+   * @param id 用户ID
+   */
+  static exit(id: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.del(`${this.url}/${id}/exit`, '', true)
+  }
+
+  /**
+   * 添加群成员
+   * @param id 群id
+   * @param userId userId
+   */
+  static addUsers(id: string, userId: string[]): Promise<AjaxResult<string[]>> {
+    return FetchRequest.post(`${this.url}/${id}/users`, JSON.stringify(userId), true)
+  }
+
+  /**
+   * 转让
+   * @param id 群id
+   * @param userId userId
+   */
+  static transference(id: string, userId: string): Promise<AjaxResult<string[]>> {
+    return FetchRequest.post(`${this.url}/${id}/transference/${userId}`, '', true)
+  }
+
+  /**
+   * 删除群
+   * @param id 用户ID
+   * @param userId 用户ID
+   */
+  static deleteUser(id: string, userId: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.del(`${this.url}/${id}/users/${userId}`, '', true)
+  }
+}
+
+export default GroupApi

+ 41 - 0
src/renderer/src/api/GroupInviteApi.ts

@@ -0,0 +1,41 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import AjaxResult from '@renderer/mode/AjaxResult'
+import GroupInvite from '@renderer/mode/GroupInvite'
+import GroupInviteCount from '@renderer/mode/GroupInviteCount'
+
+class GroupInviteApi {
+  //基础url
+  static url = '/api/sys/groupInvites'
+
+  /**
+   * 查询当前待审核的群邀请
+   */
+  static list(groupId: string): Promise<AjaxResult<GroupInvite[]>> {
+    return FetchRequest.get(`${this.url}?groupId=${groupId}`, true)
+  }
+
+  /**
+   * 查询当前待审核的群邀请
+   */
+  static waitCheckList(): Promise<AjaxResult<GroupInviteCount[]>> {
+    return FetchRequest.get(`${this.url}/waitCheckList`, true)
+  }
+
+  /**
+   * 同意加群
+   * @param id 邀请id
+   */
+  static agree(id: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.post(`${this.url}/agree/${id}`, '', true)
+  }
+
+  /**
+   * 拒绝加群
+   * @param id 邀请id
+   */
+  static refuse(id: string): Promise<AjaxResult<boolean>> {
+    return FetchRequest.post(`${this.url}/refuse/${id}`, '', true)
+  }
+}
+
+export default GroupInviteApi

+ 34 - 0
src/renderer/src/api/ImmunityApi.ts

@@ -0,0 +1,34 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import AjaxResult from '@renderer/mode/AjaxResult'
+import Immunity from '@renderer/mode/Immunity'
+
+class ImmunityApi {
+  static url = '/api/sys/immunity'
+
+  /**
+   * 获取用户免打扰
+   */
+  static list(userId: string): Promise<AjaxResult<Immunity[]>> {
+    return FetchRequest.get(`${this.url}/${userId}`, true)
+  }
+
+  /**
+   *  保存用户免打扰
+   */
+  static save(userId: string, chatId: string): Promise<AjaxResult<Immunity[]>> {
+    const immunity: Immunity = {
+      userId: userId,
+      chatId: chatId
+    }
+    return FetchRequest.post(this.url, JSON.stringify(immunity), true)
+  }
+
+  /**
+   * 删除用户免打扰
+   */
+  static delete(userId: string, chatId: string): Promise<AjaxResult<Immunity[]>> {
+    return FetchRequest.del(`${this.url}/${userId}-${chatId}`, '', true)
+  }
+}
+
+export default ImmunityApi

+ 32 - 0
src/renderer/src/api/Login.ts

@@ -0,0 +1,32 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+import packageJson from '../../../../package.json'
+
+const version = packageJson.version
+interface loginBody {
+  username: string
+  password: string
+  code: string
+  uuid: string
+}
+
+// 登录方法
+export const login = (username: string, password: string, code: string, uuid: string) => {
+  const data: loginBody = {
+    username,
+    password,
+    code,
+    uuid
+  }
+  return FetchRequest.post('/login', JSON.stringify(data), false)
+}
+
+// 注册方法
+export const register = (data: any) => FetchRequest.post('/register', JSON.stringify(data), false)
+
+// 退出方法
+export const logout = () => FetchRequest.post('/logout', '', true)
+
+// 获取验证码
+export const getCodeImg = () => FetchRequest.get(`/captchaImage?version=${version}`, false)

+ 56 - 0
src/renderer/src/api/MessageApi.ts

@@ -0,0 +1,56 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import AjaxResult from '@renderer/mode/AjaxResult'
+import Page from '@renderer/mode/Page'
+import Message from '@renderer/mode/Message'
+
+class MessageApi {
+  static url = '/api/sys/messages'
+
+  /**
+   * 消息列表
+   * @param chatId 群id或者用户id
+   * @param fromId 发送用户id
+   * @param type 消息类型
+   * @param pageSize 页面大小
+   */
+  static list(
+    chatId: string,
+    fromId: string,
+    type: string,
+    pageSize: number
+  ): Promise<AjaxResult<Message[]>> {
+    const param = `?chatId=${chatId}&fromId=${fromId}&type=${type}&pageSize=${pageSize}`
+    return FetchRequest.get(this.url + param, true)
+  }
+
+  static get(id: string,chatKey: string): Promise<AjaxResult<Message>> {
+    return FetchRequest.get(`${this.url}/${id}?chatKey=${chatKey}`, true)
+  }
+
+  static page(
+    chatId: string,
+    fromId: string,
+    searchText: string,
+    type: string,
+    messageType: string,
+    current: number,
+    dateRange1: string,
+    dateRange2: string,
+    size: number
+  ): Promise<AjaxResult<Page<Message>>> {
+    let param = `?chatId=${chatId}&fromId=${fromId}&searchText=${searchText}&chatType=${type}&messageType=${messageType}&current=${current}&size=${size}`
+    if (dateRange1 != null && dateRange1 != '') {
+      param += `&dateRange=${dateRange1}`
+    }
+    if (dateRange2 != null && dateRange2 != '') {
+      param += `&dateRange=${dateRange2}`
+    }
+    return FetchRequest.get(`${this.url}/page${param}`, true)
+  }
+
+  static getReadTime(chatId: string, fromId: string): Promise<AjaxResult<string>> {
+    return FetchRequest.get(`${this.url}/getReadTime?chatId=${chatId}&fromId=${fromId}`, true)
+  }
+}
+
+export default MessageApi

+ 25 - 0
src/renderer/src/api/SettingApi.ts

@@ -0,0 +1,25 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import AjaxResult from '@renderer/mode/AjaxResult'
+import Setting from '@renderer/mode/Setting'
+
+class SettingApi {
+  static url = '/api/sys/setting'
+
+  /**
+   * 获取用户设置
+   * @param userId 用户id
+   */
+  static get(userId: string): Promise<AjaxResult<Setting>> {
+    return FetchRequest.get(`${this.url}/${userId}`, true)
+  }
+
+  /**
+   * 修改聊天设置
+   *  @param setting 聊天设置
+   */
+  static update(setting: Setting): Promise<AjaxResult<boolean>> {
+    return FetchRequest.put(this.url, JSON.stringify(setting), true)
+  }
+}
+
+export default SettingApi

+ 66 - 0
src/renderer/src/api/UserApi.ts

@@ -0,0 +1,66 @@
+import FetchRequest from '@renderer/api/FetchRequest'
+import User from '@renderer/mode/User'
+import AjaxResult from '@renderer/mode/AjaxResult'
+
+/**
+ * 用户接口
+ */
+class UserApi {
+  static url = '/api/sys/users'
+
+  /**
+   * 根据id获取用户信息
+   * @param id id
+   */
+  static getUser(id: string): Promise<AjaxResult<User>> {
+    return FetchRequest.get(`${this.url}/${id}`, true)
+  }
+
+  /**
+   * 获取当前用户信息
+   * @returns Promise
+   */
+  static currentUser(): Promise<AjaxResult<User>> {
+    return FetchRequest.get(`${this.url}/my`, true)
+  }
+
+  /**
+   * 更新用户信息
+   * @param id id
+   * @param user  user
+   */
+  static update(id: string, user: User): Promise<AjaxResult<boolean>> {
+    user.id = id
+    return FetchRequest.put(`${this.url}/update`, JSON.stringify(user), true)
+  }
+
+  /**
+   * 用户在线状态
+   */
+  static wsOnline(): Promise<any> {
+    return FetchRequest.get('/wsOnline', true)
+  }
+
+  /**
+   * search好友
+   * @param mobile mobile
+   */
+  static search(mobile: string): Promise<AjaxResult<User[]>> {
+    return FetchRequest.get(`${this.url}/search?mobile=${mobile}`, true)
+  }
+
+  /**
+   * 刷新用户密钥
+   * @param oldPassword 旧密码
+   * @param newPassword 新密码
+   */
+  static updateUserPwd(oldPassword: string, newPassword: string): Promise<AjaxResult<boolean>> {
+    const data = {
+      oldPassword: oldPassword,
+      newPassword: newPassword
+    }
+    return FetchRequest.put(`${this.url}/updatePwd`, JSON.stringify(data), true)
+  }
+}
+
+export default UserApi

+ 256 - 0
src/renderer/src/api/WsRequest.ts

@@ -0,0 +1,256 @@
+import Message from '@renderer/mode/Message'
+import Receipt from '@renderer/mode/Receipt'
+import SendCode from '@renderer/utils/SendCode'
+import ChatUtils from '@renderer/utils/ChatUtils'
+import vimConfig from '@renderer/config/VimConfig'
+import Auth from '@renderer/api/Auth'
+import ChatType from '@renderer/utils/ChatType'
+import MessageType from '@renderer/utils/MessageType'
+import { nextTick } from 'vue'
+import { useUserStore } from '@renderer/store/userStore'
+import { useChatStore } from '@renderer/store/chatStore'
+import { useFriendStore } from '@renderer/store/friendStore'
+import { useGroupStore } from '@renderer/store/groupStore'
+import { ElMessage } from 'element-plus'
+import VimPlugin from '@renderer/plugins/VimPlugin'
+
+const ready = `{"code":${SendCode.READY}}`
+const ping = `{"code":${SendCode.PING}}`
+
+class WsRequest {
+  lockReconnect: boolean
+  url: string | undefined
+  //是否主动关闭
+  closeByUser: boolean
+  timeout: number
+  timeoutError: number
+  heartTask: NodeJS.Timeout | null
+  reconnectTimeoutTask: NodeJS.Timeout | null
+  socket: WebSocket | null
+  uuid: string
+
+  private static instance: WsRequest
+
+  private constructor() {
+    this.lockReconnect = false //避免重复连接
+    this.url = ''
+    //是否主动关闭
+    this.closeByUser = false
+    //心跳检测 多少秒执行检测
+    this.timeout = 3000
+    //超过多少秒没反应就重连
+    this.timeoutError = 5000
+    this.heartTask = null
+    this.reconnectTimeoutTask = null
+    this.socket = null
+    this.uuid = ChatUtils.uuid()
+  }
+
+  static getInstance() {
+    if (!this.instance) {
+      this.instance = new WsRequest()
+    }
+    return this.instance
+  }
+
+  public init(): void {
+    this.closeByUser = false
+    this.url = `${vimConfig.wsProtocol}://${Auth.getIp()}:${
+      vimConfig.wsPort
+    }?token=${Auth.getToken()}&client=${vimConfig.client}&uuid=${this.uuid}`
+    this.socket = new WebSocket(this.url)
+    this.socket.onopen = () => {
+      //告知服务器准备就绪
+      this.send(ready)
+      // 清除重连定时器
+      if (this.reconnectTimeoutTask) {
+        clearTimeout(this.reconnectTimeoutTask)
+      }
+      // 开启检测
+      this.reset()
+    }
+
+    // 如果希望websocket连接一直保持,在close或者error上绑定重新连接方法。
+    this.socket.onclose = () => {
+      if (!this.closeByUser) {
+        this.reconnect()
+      }
+    }
+
+    this.socket.onerror = () => {
+      this.reconnect()
+    }
+
+    this.socket.onmessage = (event: MessageEvent) => {
+      const data = event.data
+      //防止每次心跳都要进行 JSON.parse 优化性能
+      if (data === ping) {
+        this.reset()
+        return
+      }
+      const sendInfo = JSON.parse(data)
+      switch (sendInfo.code) {
+        // 真正的消息类型
+        case SendCode.MESSAGE:
+          this.onmessage(sendInfo.message)
+          break
+        case SendCode.OTHER_LOGIN:
+          if (sendInfo.message.uuid !== this.uuid) {
+            ElMessage.info('账号已经在别处登录')
+            Auth.logout()
+          }
+          break
+        case SendCode.NEW_FRIEND:
+          useFriendStore().loadWaitCheckList()
+          useFriendStore().loadData()
+          break
+        case SendCode.GROUP_VALIDATE:
+          useGroupStore().loadWaitCheckList()
+          break
+        case SendCode.READ:
+          useChatStore().setLastReadTime(sendInfo.message)
+          break
+        default:
+          VimPlugin.messageListener(sendInfo)
+          break
+      }
+      //接受任何消息都说明当前连接是正常的
+      this.reset()
+    }
+  }
+
+  /**
+   * 发送状态
+   * @param value
+   */
+  send(value: string): void {
+    this.socket?.send(value)
+  }
+
+  /**
+   * 收到消息
+   * @param message 消息
+   */
+  onmessage = (message: Message): void => {
+    const user = useUserStore().getUser()
+    //群聊里面,自己发的消息不再显示
+    if (user?.id === message.fromId) {
+      message.mine = true
+    }
+    //友聊换chatId,chatId 不一样
+    if (ChatType.FRIEND === message.type && user?.id !== message.fromId) {
+      message.chatId = message.fromId
+    }
+    if (message.messageType === MessageType.back) {
+      useChatStore().backMessage(message)
+    } else {
+      useChatStore().pushMessage(message)
+    }
+    //确保消息已经渲染到页面上了,再去滚动到底部
+    nextTick(() => {
+      ChatUtils.imageLoad('message-box')
+    }).then(() => {})
+  }
+
+  /**
+   * 发送真正的聊天消息
+   * @param message 消息
+   */
+  sendMessage(message: Message): void {
+    const sendInfo = {
+      code: SendCode.MESSAGE,
+      message: message
+    }
+    this.send(JSON.stringify(sendInfo))
+  }
+
+  /**
+   *  立即验证连接有效性
+   *  重置心跳检测和重连检测
+   *  立刻发送一个心跳信息
+   *  如果没有收到消息,就会执行重连
+   */
+  checkStatus(): void {
+    // 清除定时器重新发送一个心跳信息
+    if (this.heartTask) {
+      clearTimeout(this.heartTask)
+    }
+    if (this.reconnectTimeoutTask) {
+      clearTimeout(this.reconnectTimeoutTask)
+    }
+    this.lockReconnect = false
+    this.send(ping)
+    //onmessage拿到消息就会清理 reconnectTimeoutTask,如果没有清理,就会执行重连
+    this.reconnectTimeoutTask = setTimeout(() => {
+      this.reconnect()
+    }, this.timeoutError - this.timeout)
+  }
+
+  /**
+   * 发送已读取消息
+   * @param receipt 消息读取回执
+   */
+  sendRead(receipt: Receipt): void {
+    const sendInfo = {
+      code: SendCode.READ,
+      message: receipt
+    }
+    this.send(JSON.stringify(sendInfo))
+  }
+
+  /**
+   *  reset和start方法主要用来控制心跳的定时。
+   */
+  reset(): void {
+    // 清除定时器重新发送一个心跳信息
+    if (this.heartTask) {
+      clearTimeout(this.heartTask)
+    }
+    if (this.reconnectTimeoutTask) {
+      clearTimeout(this.reconnectTimeoutTask)
+    }
+    this.lockReconnect = false
+    this.heartTask = setTimeout(() => {
+      //这里发送一个心跳,后端收到后,返回一个心跳消息,
+      //onmessage拿到返回的心跳就说明连接正常
+      this.send(ping)
+    }, this.timeout)
+    //onmessage拿到消息就会清理 reconnectTimeoutTask,如果没有清理,就会执行重连
+    this.reconnectTimeoutTask = setTimeout(() => {
+      this.reconnect()
+    }, this.timeoutError)
+  }
+
+  // 重连
+  reconnect(): void {
+    // 防止多个方法调用,多处重连
+    if (this.lockReconnect) {
+      return
+    }
+    this.lockReconnect = true
+    //没连接上会一直重连,设置延迟避免请求过多
+    this.reconnectTimeoutTask = setTimeout(() => {
+      // 重新连接
+      this.init()
+      this.lockReconnect = false
+    }, this.timeoutError)
+  }
+
+  // 手动关闭
+  close(): void {
+    this.lockReconnect = false
+    //主动关闭
+    if (this.heartTask) {
+      clearTimeout(this.heartTask)
+    }
+    if (this.reconnectTimeoutTask) {
+      clearTimeout(this.reconnectTimeoutTask)
+    }
+    this.closeByUser = true
+    if (this.socket) {
+      this.socket.close()
+    }
+  }
+}
+
+export default WsRequest

BIN
src/renderer/src/assets/bg.png


BIN
src/renderer/src/assets/calling.mp3


BIN
src/renderer/src/assets/empty.ico


+ 367 - 0
src/renderer/src/assets/font/iconfont.css

@@ -0,0 +1,367 @@
+@font-face {
+  font-family: "iconfont"; /* Project id 3126976 */
+  src: url('iconfont.woff2?t=1712316162006') format('woff2'),
+       url('iconfont.woff?t=1712316162006') format('woff'),
+       url('iconfont.ttf?t=1712316162006') format('truetype');
+}
+
+.iconfont {
+  font-family: "iconfont" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-v-edit1:before {
+  content: "\e634";
+}
+
+.icon-v-shipin1:before {
+  content: "\e619";
+}
+
+.icon-v-yuyintonghua-jieru:before {
+  content: "\e65e";
+}
+
+.icon-v-yuyintonghua-jujie:before {
+  content: "\e660";
+}
+
+.icon-v-fujian1:before {
+  content: "\e86a";
+}
+
+.icon-v-icon-test:before {
+  content: "\e605";
+}
+
+.icon-v-fujian:before {
+  content: "\e66e";
+}
+
+.icon-v-shipintonghua-tianchong:before {
+  content: "\e7dd";
+}
+
+.icon-v-dianyingshipin:before {
+  content: "\e60e";
+}
+
+.icon-v-gonggao:before {
+  content: "\e603";
+}
+
+.icon-v-quanbugengduo:before {
+  content: "\e71e";
+}
+
+.icon-v-quanbu1:before {
+  content: "\e684";
+}
+
+.icon-v-duanxinqunfa_o:before {
+  content: "\ebc9";
+}
+
+.icon-v-xia:before {
+  content: "\e64b";
+}
+
+.icon-v-icon_xinyong_xianxing_jijin-284:before {
+  content: "\e66d";
+}
+
+.icon-v-zuzhijiegou:before {
+  content: "\e757";
+}
+
+.icon-v-xitongtuisong:before {
+  content: "\e601";
+}
+
+.icon-v-aixin:before {
+  content: "\e8c3";
+}
+
+.icon-v-tupian1:before {
+  content: "\e695";
+}
+
+.icon-v-yuyin:before {
+  content: "\e687";
+}
+
+.icon-v-wj-wjj:before {
+  content: "\e7b8";
+}
+
+.icon-v-24gl-fileText:before {
+  content: "\eabe";
+}
+
+.icon-v-quanbu:before {
+  content: "\e61c";
+}
+
+.icon-v-shoucang:before {
+  content: "\e7ce";
+}
+
+.icon-v-jietu-2:before {
+  content: "\e662";
+}
+
+.icon-v-tongji:before {
+  content: "\e638";
+}
+
+.icon-v-zaixian:before {
+  content: "\e68c";
+}
+
+.icon-v-kuaijiehuifuguanli:before {
+  content: "\e631";
+}
+
+.icon-v-kuaijiehuifu:before {
+  content: "\e652";
+}
+
+.icon-v-delete:before {
+  content: "\e732";
+}
+
+.icon-v-dian:before {
+  content: "\e608";
+}
+
+.icon-v-qunfa:before {
+  content: "\e802";
+}
+
+.icon-v-zhiding:before {
+  content: "\e62b";
+}
+
+.icon-v-quxiaozhiding:before {
+  content: "\e636";
+}
+
+.icon-v-edit:before {
+  content: "\e637";
+}
+
+.icon-v-weibiaoti2:before {
+  content: "\e621";
+}
+
+.icon-v-voice:before {
+  content: "\e6d0";
+}
+
+.icon-v-yuyinbofang:before {
+  content: "\e617";
+}
+
+.icon-v-folderHeart:before {
+  content: "\eabf";
+}
+
+.icon-v-check-item:before {
+  content: "\e669";
+}
+
+.icon-v-smile:before {
+  content: "\e626";
+}
+
+.icon-v-add-fill:before {
+  content: "\e777";
+}
+
+.icon-v-yk_fangkuai:before {
+  content: "\e600";
+}
+
+.icon-v-chuangkou01:before {
+  content: "\e667";
+}
+
+.icon-v-back:before {
+  content: "\e697";
+}
+
+.icon-v-close:before {
+  content: "\e69a";
+}
+
+.icon-v-favorite:before {
+  content: "\e6a0";
+}
+
+.icon-v-add:before {
+  content: "\e6b9";
+}
+
+.icon-v-cut:before {
+  content: "\e6f8";
+}
+
+.icon-v-f00c:before {
+  content: "\e618";
+}
+
+.icon-v-xiaoxitixingchuangkoudanchufangshi:before {
+  content: "\e625";
+}
+
+.icon-v-wode:before {
+  content: "\e658";
+}
+
+.icon-v-liaotian:before {
+  content: "\e60f";
+}
+
+.icon-v-bumen:before {
+  content: "\e64e";
+}
+
+.icon-v-haoyou:before {
+  content: "\e629";
+}
+
+.icon-v-qunzhong:before {
+  content: "\e651";
+}
+
+.icon-v-dakai:before {
+  content: "\e60a";
+}
+
+.icon-v-guanbi1:before {
+  content: "\e610";
+}
+
+.icon-v-zuixiaohua:before {
+  content: "\e616";
+}
+
+.icon-v-zuidahua:before {
+  content: "\e65c";
+}
+
+.icon-v-24gl-minimization:before {
+  content: "\ea6a";
+}
+
+.icon-v-dianhua:before {
+  content: "\e61b";
+}
+
+.icon-v-dianhuajianpan:before {
+  content: "\e61d";
+}
+
+.icon-v-huchudianhua:before {
+  content: "\e61e";
+}
+
+.icon-v-laidianxianshi:before {
+  content: "\e61f";
+}
+
+.icon-v-mima:before {
+  content: "\e620";
+}
+
+.icon-v-qunzu:before {
+  content: "\e628";
+}
+
+.icon-v-shezhi:before {
+  content: "\e62a";
+}
+
+.icon-v-shizhong:before {
+  content: "\e62c";
+}
+
+.icon-v-shouye:before {
+  content: "\e62d";
+}
+
+.icon-v-sousuo:before {
+  content: "\e62f";
+}
+
+.icon-v-tianjiayonghu:before {
+  content: "\e630";
+}
+
+.icon-v-yonghu:before {
+  content: "\e633";
+}
+
+.icon-v-duihuaxinxi:before {
+  content: "\e639";
+}
+
+.icon-v-rili:before {
+  content: "\e63a";
+}
+
+.icon-v-shipin:before {
+  content: "\e63b";
+}
+
+.icon-v-tupian:before {
+  content: "\e63e";
+}
+
+.icon-v-bangzhu:before {
+  content: "\e64a";
+}
+
+.icon-v-fenxiang:before {
+  content: "\e650";
+}
+
+.icon-v-biaoqian:before {
+  content: "\e65b";
+}
+
+.icon-v-erweima:before {
+  content: "\e65f";
+}
+
+.icon-v-shangchuan:before {
+  content: "\e665";
+}
+
+.icon-v-shuaxin:before {
+  content: "\e666";
+}
+
+.icon-v-xiazai:before {
+  content: "\e668";
+}
+
+.icon-v-cuowutishi:before {
+  content: "\e66c";
+}
+
+.icon-v-guanbi:before {
+  content: "\e71b";
+}
+
+.icon-v-tongxunlu:before {
+  content: "\e722";
+}
+
+.icon-v-xinxi:before {
+  content: "\e724";
+}
+

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
src/renderer/src/assets/font/iconfont.js


+ 625 - 0
src/renderer/src/assets/font/iconfont.json

@@ -0,0 +1,625 @@
+{
+  "id": "3126976",
+  "name": "v-im-vue3",
+  "font_family": "iconfont",
+  "css_prefix_text": "icon-v-",
+  "description": "v-im-vue3",
+  "glyphs": [
+    {
+      "icon_id": "1433725",
+      "name": "edit",
+      "font_class": "edit1",
+      "unicode": "e634",
+      "unicode_decimal": 58932
+    },
+    {
+      "icon_id": "1638517",
+      "name": "视频",
+      "font_class": "shipin1",
+      "unicode": "e619",
+      "unicode_decimal": 58905
+    },
+    {
+      "icon_id": "14130256",
+      "name": "语音通话-接入",
+      "font_class": "yuyintonghua-jieru",
+      "unicode": "e65e",
+      "unicode_decimal": 58974
+    },
+    {
+      "icon_id": "14130262",
+      "name": "语音通话-拒接",
+      "font_class": "yuyintonghua-jujie",
+      "unicode": "e660",
+      "unicode_decimal": 58976
+    },
+    {
+      "icon_id": "8288995",
+      "name": "附件",
+      "font_class": "fujian1",
+      "unicode": "e86a",
+      "unicode_decimal": 59498
+    },
+    {
+      "icon_id": "1570211",
+      "name": "开启免打扰",
+      "font_class": "icon-test",
+      "unicode": "e605",
+      "unicode_decimal": 58885
+    },
+    {
+      "icon_id": "6056150",
+      "name": "附件",
+      "font_class": "fujian",
+      "unicode": "e66e",
+      "unicode_decimal": 58990
+    },
+    {
+      "icon_id": "4553734",
+      "name": "视频通话-填充",
+      "font_class": "shipintonghua-tianchong",
+      "unicode": "e7dd",
+      "unicode_decimal": 59357
+    },
+    {
+      "icon_id": "4608981",
+      "name": "电影,视频",
+      "font_class": "dianyingshipin",
+      "unicode": "e60e",
+      "unicode_decimal": 58894
+    },
+    {
+      "icon_id": "15919411",
+      "name": "公告",
+      "font_class": "gonggao",
+      "unicode": "e603",
+      "unicode_decimal": 58883
+    },
+    {
+      "icon_id": "7093671",
+      "name": "全部 更多",
+      "font_class": "quanbugengduo",
+      "unicode": "e71e",
+      "unicode_decimal": 59166
+    },
+    {
+      "icon_id": "7637630",
+      "name": "全部",
+      "font_class": "quanbu1",
+      "unicode": "e684",
+      "unicode_decimal": 59012
+    },
+    {
+      "icon_id": "5388063",
+      "name": "短信群发_o",
+      "font_class": "duanxinqunfa_o",
+      "unicode": "ebc9",
+      "unicode_decimal": 60361
+    },
+    {
+      "icon_id": "17391173",
+      "name": "下",
+      "font_class": "xia",
+      "unicode": "e64b",
+      "unicode_decimal": 58955
+    },
+    {
+      "icon_id": "33189753",
+      "name": "组织",
+      "font_class": "icon_xinyong_xianxing_jijin-284",
+      "unicode": "e66d",
+      "unicode_decimal": 58989
+    },
+    {
+      "icon_id": "14223720",
+      "name": "组织结构",
+      "font_class": "zuzhijiegou",
+      "unicode": "e757",
+      "unicode_decimal": 59223
+    },
+    {
+      "icon_id": "1183",
+      "name": "系统推送",
+      "font_class": "xitongtuisong",
+      "unicode": "e601",
+      "unicode_decimal": 58881
+    },
+    {
+      "icon_id": "11372756",
+      "name": "爱心",
+      "font_class": "aixin",
+      "unicode": "e8c3",
+      "unicode_decimal": 59587
+    },
+    {
+      "icon_id": "666891",
+      "name": "图片",
+      "font_class": "tupian1",
+      "unicode": "e695",
+      "unicode_decimal": 59029
+    },
+    {
+      "icon_id": "837612",
+      "name": "语音",
+      "font_class": "yuyin",
+      "unicode": "e687",
+      "unicode_decimal": 59015
+    },
+    {
+      "icon_id": "6834996",
+      "name": "文件-文件夹",
+      "font_class": "wj-wjj",
+      "unicode": "e7b8",
+      "unicode_decimal": 59320
+    },
+    {
+      "icon_id": "7594804",
+      "name": "24gl-fileText",
+      "font_class": "24gl-fileText",
+      "unicode": "eabe",
+      "unicode_decimal": 60094
+    },
+    {
+      "icon_id": "9841808",
+      "name": "全部",
+      "font_class": "quanbu",
+      "unicode": "e61c",
+      "unicode_decimal": 58908
+    },
+    {
+      "icon_id": "435955",
+      "name": "收藏",
+      "font_class": "shoucang",
+      "unicode": "e7ce",
+      "unicode_decimal": 59342
+    },
+    {
+      "icon_id": "1299861",
+      "name": "Screenshots-1",
+      "font_class": "jietu-2",
+      "unicode": "e662",
+      "unicode_decimal": 58978
+    },
+    {
+      "icon_id": "807975",
+      "name": "统计",
+      "font_class": "tongji",
+      "unicode": "e638",
+      "unicode_decimal": 58936
+    },
+    {
+      "icon_id": "11810510",
+      "name": "在线",
+      "font_class": "zaixian",
+      "unicode": "e68c",
+      "unicode_decimal": 59020
+    },
+    {
+      "icon_id": "13875882",
+      "name": "快捷回复管理",
+      "font_class": "kuaijiehuifuguanli",
+      "unicode": "e631",
+      "unicode_decimal": 58929
+    },
+    {
+      "icon_id": "26677208",
+      "name": "QuickReplyOutlined",
+      "font_class": "kuaijiehuifu",
+      "unicode": "e652",
+      "unicode_decimal": 58962
+    },
+    {
+      "icon_id": "6308251",
+      "name": "delete",
+      "font_class": "delete",
+      "unicode": "e732",
+      "unicode_decimal": 59186
+    },
+    {
+      "icon_id": "1920286",
+      "name": "点",
+      "font_class": "dian",
+      "unicode": "e608",
+      "unicode_decimal": 58888
+    },
+    {
+      "icon_id": "4458929",
+      "name": "群发",
+      "font_class": "qunfa",
+      "unicode": "e802",
+      "unicode_decimal": 59394
+    },
+    {
+      "icon_id": "1817757",
+      "name": "置顶",
+      "font_class": "zhiding",
+      "unicode": "e62b",
+      "unicode_decimal": 58923
+    },
+    {
+      "icon_id": "21295783",
+      "name": "取消置顶",
+      "font_class": "quxiaozhiding",
+      "unicode": "e636",
+      "unicode_decimal": 58934
+    },
+    {
+      "icon_id": "1138729",
+      "name": "edit",
+      "font_class": "edit",
+      "unicode": "e637",
+      "unicode_decimal": 58935
+    },
+    {
+      "icon_id": "459800",
+      "name": "qw-switch",
+      "font_class": "weibiaoti2",
+      "unicode": "e621",
+      "unicode_decimal": 58913
+    },
+    {
+      "icon_id": "347690",
+      "name": "voice",
+      "font_class": "voice",
+      "unicode": "e6d0",
+      "unicode_decimal": 59088
+    },
+    {
+      "icon_id": "4418236",
+      "name": "语音播放",
+      "font_class": "yuyinbofang",
+      "unicode": "e617",
+      "unicode_decimal": 58903
+    },
+    {
+      "icon_id": "7594809",
+      "name": "24gl-folderHeart",
+      "font_class": "folderHeart",
+      "unicode": "eabf",
+      "unicode_decimal": 60095
+    },
+    {
+      "icon_id": "15838445",
+      "name": "check-item",
+      "font_class": "check-item",
+      "unicode": "e669",
+      "unicode_decimal": 58985
+    },
+    {
+      "icon_id": "1471610",
+      "name": "smile",
+      "font_class": "smile",
+      "unicode": "e626",
+      "unicode_decimal": 58918
+    },
+    {
+      "icon_id": "11488225",
+      "name": "add-fill",
+      "font_class": "add-fill",
+      "unicode": "e777",
+      "unicode_decimal": 59255
+    },
+    {
+      "icon_id": "6141181",
+      "name": "AK-YK_方块",
+      "font_class": "yk_fangkuai",
+      "unicode": "e600",
+      "unicode_decimal": 58880
+    },
+    {
+      "icon_id": "781531",
+      "name": "窗口-01",
+      "font_class": "chuangkou01",
+      "unicode": "e667",
+      "unicode_decimal": 58983
+    },
+    {
+      "icon_id": "122688",
+      "name": "back",
+      "font_class": "back",
+      "unicode": "e697",
+      "unicode_decimal": 59031
+    },
+    {
+      "icon_id": "122691",
+      "name": "close",
+      "font_class": "close",
+      "unicode": "e69a",
+      "unicode_decimal": 59034
+    },
+    {
+      "icon_id": "122697",
+      "name": "favorite",
+      "font_class": "favorite",
+      "unicode": "e6a0",
+      "unicode_decimal": 59040
+    },
+    {
+      "icon_id": "122722",
+      "name": "add",
+      "font_class": "add",
+      "unicode": "e6b9",
+      "unicode_decimal": 59065
+    },
+    {
+      "icon_id": "240311",
+      "name": "cut",
+      "font_class": "cut",
+      "unicode": "e6f8",
+      "unicode_decimal": 59128
+    },
+    {
+      "icon_id": "331003",
+      "name": "F00C",
+      "font_class": "f00c",
+      "unicode": "e618",
+      "unicode_decimal": 58904
+    },
+    {
+      "icon_id": "886913",
+      "name": "消息提醒窗口弹出方式",
+      "font_class": "xiaoxitixingchuangkoudanchufangshi",
+      "unicode": "e625",
+      "unicode_decimal": 58917
+    },
+    {
+      "icon_id": "806194",
+      "name": "我的",
+      "font_class": "wode",
+      "unicode": "e658",
+      "unicode_decimal": 58968
+    },
+    {
+      "icon_id": "2551777",
+      "name": "聊天",
+      "font_class": "liaotian",
+      "unicode": "e60f",
+      "unicode_decimal": 58895
+    },
+    {
+      "icon_id": "5121532",
+      "name": "部门",
+      "font_class": "bumen",
+      "unicode": "e64e",
+      "unicode_decimal": 58958
+    },
+    {
+      "icon_id": "10060846",
+      "name": "好友",
+      "font_class": "haoyou",
+      "unicode": "e629",
+      "unicode_decimal": 58921
+    },
+    {
+      "icon_id": "22236000",
+      "name": "群众",
+      "font_class": "qunzhong",
+      "unicode": "e651",
+      "unicode_decimal": 58961
+    },
+    {
+      "icon_id": "26659194",
+      "name": "打开",
+      "font_class": "dakai",
+      "unicode": "e60a",
+      "unicode_decimal": 58890
+    },
+    {
+      "icon_id": "26659251",
+      "name": "关闭",
+      "font_class": "guanbi1",
+      "unicode": "e610",
+      "unicode_decimal": 58896
+    },
+    {
+      "icon_id": "26659380",
+      "name": "最小化",
+      "font_class": "zuixiaohua",
+      "unicode": "e616",
+      "unicode_decimal": 58902
+    },
+    {
+      "icon_id": "428403",
+      "name": "最大化",
+      "font_class": "zuidahua",
+      "unicode": "e65c",
+      "unicode_decimal": 58972
+    },
+    {
+      "icon_id": "7594034",
+      "name": "24gl-minimization",
+      "font_class": "24gl-minimization",
+      "unicode": "ea6a",
+      "unicode_decimal": 60010
+    },
+    {
+      "icon_id": "144688",
+      "name": "电话",
+      "font_class": "dianhua",
+      "unicode": "e61b",
+      "unicode_decimal": 58907
+    },
+    {
+      "icon_id": "144703",
+      "name": "电话键盘",
+      "font_class": "dianhuajianpan",
+      "unicode": "e61d",
+      "unicode_decimal": 58909
+    },
+    {
+      "icon_id": "144704",
+      "name": "呼出电话",
+      "font_class": "huchudianhua",
+      "unicode": "e61e",
+      "unicode_decimal": 58910
+    },
+    {
+      "icon_id": "144705",
+      "name": "来电显示",
+      "font_class": "laidianxianshi",
+      "unicode": "e61f",
+      "unicode_decimal": 58911
+    },
+    {
+      "icon_id": "144706",
+      "name": "密码",
+      "font_class": "mima",
+      "unicode": "e620",
+      "unicode_decimal": 58912
+    },
+    {
+      "icon_id": "145431",
+      "name": "群组",
+      "font_class": "qunzu",
+      "unicode": "e628",
+      "unicode_decimal": 58920
+    },
+    {
+      "icon_id": "145433",
+      "name": "设置",
+      "font_class": "shezhi",
+      "unicode": "e62a",
+      "unicode_decimal": 58922
+    },
+    {
+      "icon_id": "145435",
+      "name": "时钟",
+      "font_class": "shizhong",
+      "unicode": "e62c",
+      "unicode_decimal": 58924
+    },
+    {
+      "icon_id": "145436",
+      "name": "首页",
+      "font_class": "shouye",
+      "unicode": "e62d",
+      "unicode_decimal": 58925
+    },
+    {
+      "icon_id": "145438",
+      "name": "搜索",
+      "font_class": "sousuo",
+      "unicode": "e62f",
+      "unicode_decimal": 58927
+    },
+    {
+      "icon_id": "145439",
+      "name": "添加用户",
+      "font_class": "tianjiayonghu",
+      "unicode": "e630",
+      "unicode_decimal": 58928
+    },
+    {
+      "icon_id": "145442",
+      "name": "用户",
+      "font_class": "yonghu",
+      "unicode": "e633",
+      "unicode_decimal": 58931
+    },
+    {
+      "icon_id": "145449",
+      "name": "对话信息",
+      "font_class": "duihuaxinxi",
+      "unicode": "e639",
+      "unicode_decimal": 58937
+    },
+    {
+      "icon_id": "145450",
+      "name": "日历",
+      "font_class": "rili",
+      "unicode": "e63a",
+      "unicode_decimal": 58938
+    },
+    {
+      "icon_id": "145451",
+      "name": "视频",
+      "font_class": "shipin",
+      "unicode": "e63b",
+      "unicode_decimal": 58939
+    },
+    {
+      "icon_id": "145454",
+      "name": "图片",
+      "font_class": "tupian",
+      "unicode": "e63e",
+      "unicode_decimal": 58942
+    },
+    {
+      "icon_id": "145466",
+      "name": "帮助",
+      "font_class": "bangzhu",
+      "unicode": "e64a",
+      "unicode_decimal": 58954
+    },
+    {
+      "icon_id": "145472",
+      "name": "分享",
+      "font_class": "fenxiang",
+      "unicode": "e650",
+      "unicode_decimal": 58960
+    },
+    {
+      "icon_id": "145484",
+      "name": "标签",
+      "font_class": "biaoqian",
+      "unicode": "e65b",
+      "unicode_decimal": 58971
+    },
+    {
+      "icon_id": "145488",
+      "name": "二维码",
+      "font_class": "erweima",
+      "unicode": "e65f",
+      "unicode_decimal": 58975
+    },
+    {
+      "icon_id": "145494",
+      "name": "上传",
+      "font_class": "shangchuan",
+      "unicode": "e665",
+      "unicode_decimal": 58981
+    },
+    {
+      "icon_id": "145495",
+      "name": "刷新",
+      "font_class": "shuaxin",
+      "unicode": "e666",
+      "unicode_decimal": 58982
+    },
+    {
+      "icon_id": "145497",
+      "name": "下载",
+      "font_class": "xiazai",
+      "unicode": "e668",
+      "unicode_decimal": 58984
+    },
+    {
+      "icon_id": "145502",
+      "name": "错误提示",
+      "font_class": "cuowutishi",
+      "unicode": "e66c",
+      "unicode_decimal": 58988
+    },
+    {
+      "icon_id": "1232396",
+      "name": "关闭",
+      "font_class": "guanbi",
+      "unicode": "e71b",
+      "unicode_decimal": 59163
+    },
+    {
+      "icon_id": "1232426",
+      "name": "通讯录",
+      "font_class": "tongxunlu",
+      "unicode": "e722",
+      "unicode_decimal": 59170
+    },
+    {
+      "icon_id": "1232433",
+      "name": "信息",
+      "font_class": "xinxi",
+      "unicode": "e724",
+      "unicode_decimal": 59172
+    }
+  ]
+}

BIN
src/renderer/src/assets/font/iconfont.ttf


BIN
src/renderer/src/assets/font/iconfont.woff


BIN
src/renderer/src/assets/font/iconfont.woff2


BIN
src/renderer/src/assets/icon.ico


BIN
src/renderer/src/assets/icon.png


+ 34 - 0
src/renderer/src/assets/icons.svg

@@ -0,0 +1,34 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+  <symbol id="electron" viewBox="0 0 900 300">
+    <g fill="none" fill-rule="evenodd">
+      <g class="hero-apps" style="fill: #71abb7;">
+        <path d="M15 138l-4.9-.64L8 133l-2.1 4.36L1 138l3.6 3.26-.93 4.74L8 143.67l4.33 2.33-.93-4.74z"></path>
+        <path d="M897.2 114.0912l-5.2 3.63v-2.72c0-.55-.45-1-1-1h-8c-.55 0-1 .45-1 1v9c0 .55.45 1 1 1h8c.55 0 1-.45 1-1v-2.72l5.2 3.63c.33.23.8 0 .8-.41v-10c0-.41-.47-.64-.8-.41z"></path>
+        <path d="M65.4 188.625h-1.6c.88 0 1.6-.7313 1.6-1.625v-1.625c0-.8937-.72-1.625-1.6-1.625h-1.6c-.88 0-1.6.7313-1.6 1.625V187c0 .8937.72 1.625 1.6 1.625h-1.6c-.88 0-1.6.7313-1.6 1.625v3.25h1.6v4.875c0 .8937.72 1.625 1.6 1.625h1.6c.88 0 1.6-.7313 1.6-1.625V193.5H67v-3.25c0-.8937-.72-1.625-1.6-1.625zm-3.2-3.25h1.6V187h-1.6v-1.625zm3.2 6.5h-1.6v6.5h-1.6v-6.5h-1.6v-1.625h4.8v1.625zm3.344-5.6875c0-3.2175-2.576-5.8337-5.744-5.8337-3.168 0-5.744 2.6162-5.744 5.8337 0 .455.048.8937.144 1.3162v3.2175c-.976-1.2512-1.6-2.8112-1.6-4.55 0-4.03 3.232-7.3125 7.2-7.3125s7.2 3.2825 7.2 7.3125c0 1.7225-.624 3.2988-1.6 4.55v-3.2175c.096-.4387.144-.8612.144-1.3162zm6.256 0c0 4.68-2.608 8.7425-6.4 10.7738v-1.7063c2.976-1.885 4.944-5.2325 4.944-9.0675 0-5.915-4.72-10.7087-10.544-10.7087-5.824 0-10.544 4.7937-10.544 10.7087 0 3.835 1.968 7.1825 4.944 9.0675v1.7063c-3.792-2.0313-6.4-6.0938-6.4-10.7738C51 179.46 56.376 174 63 174s12 5.46 12 12.1875z"></path>
+        <path d="M830.7143 142.3333c-.8643 0-1.5714.7125-1.5714 1.5834v3.1666c0 .871.707 1.5834 1.5713 1.5834h12.5714c.8643 0 1.5714-.7125 1.5714-1.5834v-3.1666c0-.871-.707-1.5834-1.5713-1.5834h-12.5714zm12.5714 2.771l-1.9643 1.979h-2.357L837 145.1043l-1.9643 1.979h-2.357l-1.9644-1.979v-1.1876h1.1786l1.964 1.979 1.9644-1.979h2.3572l1.9643 1.979 1.964-1.979h1.1787v1.1875zm-9.4286 5.1457h6.286v1.5833h-6.286V150.25zM837 136c-6.0657 0-11 4.6075-11 10.2917v7.125c0 .8708.707 1.5833 1.5714 1.5833h18.8572c.8643 0 1.5714-.7125 1.5714-1.5833v-7.125C848 140.6075 843.0657 136 837 136zm9.4286 17.4167h-18.8572v-7.125c0-4.8925 4.1486-8.851 9.4286-8.851 5.28 0 9.4286 3.9585 9.4286 8.851v7.125z"></path>
+        <path d="M75 91.8065V96h4.1935L90.376 84.8174l-4.1934-4.1935L75 91.8064zm4.1935 2.7957h-2.7957v-2.7957h1.398v1.3978h1.3977v1.398zM93.591 81.6024l-1.817 1.817-4.1935-4.1934 1.817-1.817c.5453-.5453 1.426-.5453 1.971 0l2.2226 2.2224c.5453.5452.5453 1.4258 0 1.971z"></path>
+        <path d="M797 187h4v4h-4v-4zm12-1v19c0 1.1-.9 2-2 2h-20c-1.1 0-2-.9-2-2v-24c0-1.1.9-2 2-2h15l7 7zm-2 1l-6-6h-14v22l6-10 4 8 4-4 6 6v-16z"></path>
+        <path d="M138 125c-6.62 0-12 5-12 11 0 9.04 12 21 12 21s12-11.96 12-21c0-6-5.38-11-12-11zm0 29.1c-3.72-4.06-10-12.22-10-18.1 0-4.96 4.5-9 10-9 2.68 0 5.22.96 7.12 2.72 1.84 1.72 2.88 3.94 2.88 6.28 0 5.88-6.28 14.04-10 18.1zm4-18.1c0 2.22-1.78 4-4 4-2.22 0-4-1.78-4-4 0-2.22 1.78-4 4-4 2.22 0 4 1.78 4 4z"></path>
+        <path d="M771 82h8v2h-8v-2zm0 6h8v-2h-8v2zm0 4h8v-2h-8v2zm22-10h-8v2h8v-2zm0 4h-8v2h8v-2zm0 4h-8v2h8v-2zm4-12v18c0 1.1-.9 2-2 2h-11l-2 2-2-2h-11c-1.1 0-2-.9-2-2V78c0-1.1.9-2 2-2h11l2 2 2-2h11c1.1 0 2 .9 2 2zm-16 1l-1-1h-11v18h12V79zm14-1h-11l-1 1v17h12V78z"></path>
+        <path d="M176 203h-24c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h4v7l7-7h13c1.1 0 2-.9 2-2v-16c0-1.1-.9-2-2-2zm0 18h-14l-4 4v-4h-6v-16h24v16z"></path>
+        <path d="M673 88.921c0 2.18-.9 4.18-2.34 5.66l-1.34-1.34c1.1-1.12 1.78-2.62 1.78-4.32 0-1.7-.68-3.22-1.78-4.32l1.34-1.34c1.44 1.44 2.34 3.44 2.34 5.66zm-8.56-11.48l-7.44 7.44h-4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h4l7.44 7.44c.94.94 2.56.28 2.56-1.06v-20.76c0-1.34-1.62-2-2.56-1.06zm11.88.16l-1.34 1.34c2.56 2.56 4.12 6.06 4.12 9.96 0 3.88-1.56 7.4-4.12 9.96l1.34 1.34c2.9-2.9 4.68-6.9 4.68-11.32 0-4.44-1.78-8.44-4.68-11.32v.04zm-2.82 2.82l-1.38 1.34c1.84 1.84 2.96 4.38 2.96 7.16 0 2.78-1.12 5.32-2.96 7.12l1.38 1.34c2.16-2.16 3.5-5.16 3.5-8.46 0-3.3-1.34-6.32-3.5-8.5z"></path>
+        <path d="M226 79h-16c0-1.1-.9-2-2-2h-8c-1.1 0-2 .9-2 2-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V81c0-1.1-.9-2-2-2zm-18 4h-8v-2h8v2zm9 14c-3.88 0-7-3.12-7-7s3.12-7 7-7 7 3.12 7 7-3.12 7-7 7zm5-7c0 2.76-2.26 5-5 5s-5-2.26-5-5 2.26-5 5-5 5 2.26 5 5z"></path>
+        <path d="M725.8393 157h-15.6498c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2h15.6298C727 153 727 153.82 727 155c0 1.18 0 2-1.1807 2h.02zm-11.6473-10c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2h11.6273C727 143 727 143.82 727 145c0 1.18 0 2-1.1807 2H714.192zM695 146.82l2.8218-2.6 3.182 3.18 8.185-8.4 2.8218 2.82-11.0068 11-6.0038-6zM710.1895 163h15.6298C727 163 727 163.82 727 165c0 1.18 0 2-1.1807 2h-15.6298c-1.1807 0-1.1807-.82-1.1807-2 0-1.18 0-2 1.1807-2z"></path>
+        <path d="M223 152v24c0 1.65 1.35 3 3 3h36c1.65 0 3-1.35 3-3v-24c0-1.65-1.35-3-3-3h-36c-1.65 0-3 1.35-3 3zm39 0l-18 15-18-15h36zm-36 4.5l12 9-12 9v-18zm3 19.5l10.5-9 4.5 4.5 4.5-4.5 10.5 9h-30zm33-1.5l-12-9 12-9v18z"></path>
+        <path d="M648 182h-3v4.5c0 .84-.66 1.5-1.5 1.5h-6c-.84 0-1.5-.66-1.5-1.5V182h-9v4.5c0 .84-.66 1.5-1.5 1.5h-6c-.84 0-1.5-.66-1.5-1.5V182h-3c-1.65 0-3 1.35-3 3v33c0 1.65 1.35 3 3 3h33c1.65 0 3-1.35 3-3v-33c0-1.65-1.35-3-3-3zm0 36h-33v-27h33v27zm-24-33h-3v-6h3v6zm18 0h-3v-6h3v6zm-15 12h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm-24 6h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3zm6 0h-3v-3h3v3z"></path>
+      </g>
+      <g class="hero-icons" style="fill: #c2f5ff;">
+        <path d="M441.1132 69.724c7.681 0 13.9075-6.207 13.9075-13.8636 0-7.6565-6.2266-13.8634-13.9075-13.8634-7.681 0-13.9076 6.207-13.9076 13.8634 0 7.6566 6.2266 13.8635 13.9076 13.8635zm0-5.7932c-4.4713 0-8.096-3.6132-8.096-8.0704 0-4.457 3.6247-8.0703 8.096-8.0703 4.4712 0 8.096 3.6133 8.096 8.0704 0 4.4572-3.6248 8.0704-8.096 8.0704z"></path>
+        <path d="M354.8995 220.2693c7.681 0 13.9075-6.207 13.9075-13.8635s-6.2266-13.8634-13.9075-13.8634c-7.681 0-13.9075 6.207-13.9075 13.8634 0 7.6566 6.2266 13.8635 13.9075 13.8635zm0-5.793c-4.4713 0-8.096-3.6133-8.096-8.0705 0-4.457 3.6247-8.0703 8.096-8.0703s8.096 3.6132 8.096 8.0703c0 4.4572-3.6247 8.0704-8.096 8.0704z"></path>
+        <path d="M541.0343 206.4058c0-7.6565-6.2266-13.8634-13.9075-13.8634-7.681 0-13.9075 6.207-13.9075 13.8634 0 7.6566 6.2266 13.8635 13.9075 13.8635 7.681 0 13.9075-6.207 13.9075-13.8635zm-5.8115 0c0 4.4572-3.6247 8.0704-8.096 8.0704s-8.096-3.6132-8.096-8.0704c0-4.457 3.6247-8.0703 8.096-8.0703s8.096 3.6132 8.096 8.0703z"></path>
+        <path d="M397.6943 214.5258c9.7012 27.0033 25.5723 43.629 43.419 43.629 13.0157 0 25.0578-8.8443 34.4482-24.4154.827-1.371.3822-3.1507-.9932-3.975-1.3755-.824-3.1607-.3808-3.9876.9902-8.439 13.9938-18.8052 21.6072-29.4675 21.6072-14.8247 0-28.9803-14.8288-37.9476-39.7892-.541-1.506-2.2044-2.2897-3.7153-1.7504-1.511.5394-2.297 2.1975-1.756 3.7036z"></path>
+        <path d="M514.124 163.4733c18.5545-21.85 25.033-43.826 16.122-59.2117-6.557-11.321-20.419-17.2982-38.841-17.537-1.6047-.021-2.9225 1.259-2.9434 2.8586-.0208 1.5996 1.263 2.9132 2.8678 2.934 16.5683.2148 28.5106 5.3642 33.8836 14.641 7.4018 12.7797 1.6243 32.3774-15.5247 52.5722-1.037 1.221-.8844 3.0487.3405 4.0822 1.2248 1.0336 3.0584.8817 4.0952-.3393z"></path>
+        <path d="M411.5672 88.457c-28.3373-5.1448-50.7424.24-59.672 15.6575-6.6635 11.505-4.7588 26.7585 4.6193 43.0637.7982 1.3878 2.574 1.8678 3.966 1.072 1.3923-.7956 1.874-2.5656 1.0756-3.9534-8.4477-14.688-10.0915-27.8524-4.628-37.2857 7.418-12.8074 27.403-17.6105 53.5978-12.8546 1.579.2866 3.092-.7568 3.3794-2.3307.2876-1.5738-.7592-3.082-2.338-3.3687z"></path>
+        <path d="M486.3075 209.2436c5.022-15.998 7.7194-34.453 7.7194-53.6842 0-47.9875-16.849-89.3545-40.8478-99.977-1.4667-.649-3.1837.0098-3.835 1.472-.6512 1.462.01 3.1735 1.4766 3.8227 21.404 9.474 37.3945 48.7337 37.3945 94.6824 0 18.6574-2.612 36.5297-7.454 51.954-.4794 1.5268.3736 3.1518 1.9052 3.6295s3.1617-.3727 3.641-1.8994z"></path>
+        <path d="M466.439 89.4215c-16.7763 3.583-34.6332 10.5886-51.7827 20.4585-42.434 24.4216-70.1147 60.4323-66.2703 86.5432.233 1.5828 1.709 2.6776 3.297 2.4453 1.5877-.2323 2.686-1.7037 2.453-3.2865-3.4135-23.1838 22.825-57.3183 63.426-80.685 16.6365-9.5746 33.9267-16.3578 50.0946-19.811 1.5692-.335 2.5687-1.8748 2.2325-3.439-.336-1.5642-1.8807-2.5606-3.45-2.2255z"></path>
+        <path d="M371.2508 166.997c11.458 12.5516 26.3438 24.3243 43.3203 34.0947 41.106 23.6572 84.866 29.9805 106.4328 15.3217 1.326-.9013 1.668-2.7033.7638-4.025-.904-1.3217-2.712-1.6626-4.0378-.7614-19.302 13.1195-60.871 7.1128-100.253-15.5523-16.469-9.4783-30.8834-20.8782-41.9277-32.9767-1.08-1.1832-2.9178-1.2695-4.1048-.1928-1.187 1.0766-1.2735 2.9086-.1934 4.0918z"></path>
+        <path d="M443.2374 165.3634c-5.432 1.17-10.7838-2.2712-11.9598-7.686-1.1714-5.415 2.2785-10.7498 7.7106-11.922 5.432-1.17 10.7838 2.2712 11.9598 7.686 1.1737 5.415-2.2785 10.7498-7.7106 11.922z"></path>
+      </g>
+    </g>
+  </symbol>
+</svg>

BIN
src/renderer/src/assets/poster.gif


+ 26 - 0
src/renderer/src/assets/styles/g.css

@@ -0,0 +1,26 @@
+* {
+    padding: 0;
+    margin: 0;
+    box-sizing: border-box;
+    font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
+}
+
+html {
+    font-size: 62.5%;
+    height: 100%;
+    overflow: hidden;
+}
+
+body {
+    font-size: 1.4rem;
+    height: 100%;
+    overflow: hidden;
+}
+
+a {
+    text-decoration: none;
+}
+
+.clear {
+    clear: both;
+}

+ 18 - 0
src/renderer/src/assets/styles/theme.less

@@ -0,0 +1,18 @@
+@width: 28rem;
+@height: 56rem;
+
+@color-write: #ffffff;
+@color-main: #2590c2;
+@color-light-main: #69cbe9;
+@color-gray: #dddddd;
+@color-light-gray: #eeeeee;
+@color-default: #666666;
+@box-shadow: #aaaaaa;
+@color-message-bg: #5fb878;
+@color-box-bg: #f8f8f8;
+
+
+@small-size: 1.2rem;
+@default-size: 1.4rem;
+@large-size: 1.6rem;
+@x-large-size: 1.8rem;

+ 146 - 0
src/renderer/src/assets/styles/v-im.less

@@ -0,0 +1,146 @@
+@charset "UTF-8";
+@import 'theme.less';
+
+.v-im {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow-y: hidden;
+  background-color: #2590c2;
+  box-shadow: @box-shadow 2px 2px 5px;
+}
+
+.v3-menus-item {
+  font-size: 1.2rem !important;
+}
+
+.pull-right {
+  float: right;
+}
+
+.text-center {
+  text-align: center;
+}
+
+/* firefox 滚动条 */
+* {
+  scrollbar-width: thin;
+}
+
+::-webkit-scrollbar {
+  width: 6px;
+  height: 1px;
+}
+
+/*滚动条里面小方块*/
+::-webkit-scrollbar-thumb {
+  -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.1);
+  background: #cccccc;
+}
+
+/*滚动条里面轨道*/
+::-webkit-scrollbar-track //scroll轨道背景
+{
+  -webkit-box-shadow: none;
+  border-radius: 10px;
+  background-color: transparent;
+}
+
+*:focus {
+  outline: none;
+}
+
+.ivu-input {
+  border: 0 !important;
+  border-radius: 0 !important;
+}
+
+
+.v-box {
+  flex: 1;
+  background-color: #eeeeee;
+  height: 100%;
+  display: flex;
+  flex-direction: row;
+
+  .v-left {
+    height: 100%;
+    width: 22rem;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .v-main {
+    flex: 1;
+    background-color: #f8f8f8;
+    display: flex;
+    flex-direction: column;
+    position: relative;
+  }
+}
+
+
+.main {
+  width: 26rem;
+  background-color: @color-light-gray;
+  height: 100%;
+  display: flex;
+  flex-direction: row;
+  color: @color-default;
+
+  a{
+    color: @color-default;
+  }
+
+  .left {
+    height: 100%;
+    width: 22rem;
+    display: flex;
+    flex-direction: column;
+
+    .title {
+      margin-bottom: 1rem;
+      padding: 1rem 1rem;
+      background-color: #eeeeee;
+      height: 50px;
+      border-bottom: #cccccc 1px solid;
+      font-weight: bold;
+      line-height: 30px;
+      .add {
+        text-align: right;
+        cursor: pointer;
+        align-items: center;
+        display: flex;
+        justify-content: flex-end;
+      }
+    }
+
+    .list {
+      flex: 1;
+      .item:first-child{
+        margin-top: 10px!important;
+      }
+    }
+  }
+
+  .right {
+    flex: 1;
+    background-color: @color-box-bg;
+    display: flex;
+    flex-direction: column;
+    position: relative;
+
+    .main-view {
+      position: absolute;
+      width: 100%;
+      top: 40px;
+      padding: 100px;
+    }
+  }
+}
+
+
+.text-right {
+  text-align: right;
+}

+ 92 - 0
src/renderer/src/components/AvatarUpload.vue

@@ -0,0 +1,92 @@
+<template>
+  <el-upload
+    :action="`${host}/${VimConfig.uploadType}/upload`"
+    :headers="headers"
+    :data="data"
+    :show-file-list="false"
+    :on-success="handleSuccess"
+    :before-upload="beforeUpload"
+  >
+    <vim-avatar v-if="avatar" size="large" :img="avatar" />
+    <i v-if="!avatar" class="iconfont icon-v-add"></i>
+  </el-upload>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, toRefs } from 'vue'
+import FetchRequest from '@renderer/api/FetchRequest'
+import Auth from '@renderer/api/Auth'
+import VimAvatar from '@renderer/components/VimAvatar.vue'
+import { compressAccurately } from 'image-conversion'
+import VimConfig from '../config/VimConfig'
+
+interface VimData {
+  host: string
+  headers: any
+  data: any
+}
+const props = defineProps({
+  avatar: {
+    type: String,
+    required: true,
+    default: ''
+  }
+})
+
+const avatar = computed(() => {
+  return props.avatar
+})
+const token = Auth.getToken()
+
+const vimData = reactive<VimData>({
+  host: FetchRequest.getHost(),
+  headers: {
+    'Access-Control-Allow-Origin': '*',
+    Authorization: 'Bearer ' + token
+  },
+  data: {
+    access_token: Auth.getToken(),
+    type: 'file'
+  }
+})
+const emits = defineEmits(['uploadSuccess'])
+//上传成功回调
+const handleSuccess = (response: any) => {
+  emits('uploadSuccess', response.url)
+}
+
+const beforeUpload = (file: File) => {
+  const size = file.size
+  return new Promise((resolve, reject) => {
+    if (size > 1024 * 512) {
+      compressAccurately(file, {
+        size: 512,
+        accuracy: 0.9,
+        width: 512
+      })
+        .then((res) => {
+          resolve(res)
+        })
+        .catch((err) => {
+          console.log(err)
+          reject(err)
+        })
+    } else {
+      return resolve(file)
+    }
+  })
+}
+
+const { host, headers, data } = toRefs(vimData)
+</script>
+
+<style scoped>
+.el-upload {
+  display: flex;
+}
+.icon-v-add{
+  font-size: 4rem;
+  background-color: #cccccc;
+  padding: 1rem;
+}
+</style>

+ 122 - 0
src/renderer/src/components/ChatGroupInfo.vue

@@ -0,0 +1,122 @@
+<template>
+  <div v-if="group" class="im-chat-users">
+    <div class="title">
+      <div>
+        <span>群公告</span>
+      </div>
+      <i v-if="isMaster" class="iconfont icon-v-edit1" @click="handleEditAnnouncement(group)"></i>
+    </div>
+    <div class="announcement">
+      {{ group?.announcement }}
+    </div>
+    <div class="title">
+      <div>
+        <span>成员</span>
+      </div>
+      <i
+        v-if="isMaster || group.openInvite === DictUtils.YES"
+        class="iconfont icon-v-add"
+        @click="showAddGroupUser(group)"
+      ></i>
+    </div>
+    <el-scrollbar class="chat-user-list">
+      <div
+        v-for="item in groupUsers"
+        :key="item.id"
+        class="user"
+        @click="group.prohibitFriend === DictUtils.YES ? '' : showUser(item.id, true)"
+      >
+        <vim-avatar :img="item.avatar" :name="item.name" :size="'small'" />
+        <div style="padding-left: 10px" :class='{master:item.id === group.master}'>{{ item.name }}</div>
+      </div>
+    </el-scrollbar>
+  </div>
+</template>
+<script setup lang="ts">
+import showUser from './user-modal/index'
+import VimAvatar from '@renderer/components/VimAvatar.vue'
+import { loadGroupData, group, groupUsers, isMaster } from '@renderer/hooks/useGroupData'
+import DictUtils from '../utils/DictUtils'
+import showAddGroupUser from './group/add-user'
+import handleEditAnnouncement from './group/announcement'
+import { computed, ref } from 'vue'
+interface IProps {
+  groupId: string
+}
+const props = defineProps<IProps>()
+loadGroupData(props.groupId)
+const master = computed(() => group?.master)
+</script>
+<style scoped lang="less">
+.title {
+  padding: 10px;
+  font-size: 12px;
+  color: #666;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  .icon-v-edit1 {
+    cursor: pointer;
+    display: block;
+  }
+  .icon-v-add {
+    cursor: pointer;
+    display: block;
+  }
+}
+.announcement {
+  padding: 10px;
+  font-size: 12px;
+  color: #666;
+  background-color: #ffffff;
+  line-height: 150%;
+  text-indent: 2em;
+  height: 130px;
+}
+.im-chat-users {
+  width: 180px;
+  border-left: 1px solid #cccccc;
+
+  .chat-user-list {
+    height: calc(100% - 205px);
+    list-style: none;
+    margin: 0;
+    background-color: #ffffff;
+    .user {
+      cursor: pointer;
+      padding: 5px 2px;
+      position: relative;
+      display: flex;
+      align-items: center;
+
+      &:hover {
+        background-color: #eeeeee;
+
+        &:after {
+          content: '...';
+          position: absolute;
+          right: 10px;
+          font-weight: bold;
+          bottom: 13px;
+        }
+      }
+
+      & > .im-chat-avatar {
+        width: 3.2rem;
+        height: 3.2rem;
+        display: inline-block;
+        vertical-align: middle;
+
+        & > img {
+          width: 100%;
+          height: 100%;
+        }
+      }
+    }
+  }
+}
+.master {
+  color: chocolate;
+  font-weight: bold;
+}
+</style>

+ 159 - 0
src/renderer/src/components/ChatItem.vue

@@ -0,0 +1,159 @@
+<template>
+  <div
+    class="item"
+    :data-id="id"
+    :class="(active ? 'active ' : ' ') + (top ? 'top' : '')"
+    @contextmenu="rightEvent(id, $event)"
+  >
+    <el-badge :value="unreadCount" :hidden="(unreadCount ?? 0) < 1">
+      <vim-avatar :img="img" :name="username" class="avatar" :is-group="isGroup" />
+    </el-badge>
+    <div class="text">
+      <div>
+        <span>{{ username }}</span>
+      </div>
+      <div v-if="text">
+        <div>{{ text }}</div>
+      </div>
+    </div>
+    <div class="last-time">
+      <div v-if="lastTime">
+        {{
+          formatDistanceToNow(lastTime, {
+            locale: zhCN,
+            addSuffix: false
+          })
+        }}
+      </div>
+      <div v-if="showNotice">
+        <i class="iconfont icon-v-icon-test"></i>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import VimAvatar from '@renderer/components/VimAvatar.vue'
+import { formatDistanceToNow } from 'date-fns'
+import { zhCN } from 'date-fns/locale'
+import { storeToRefs } from 'pinia'
+import { useImmunityStore } from '../store/immunityStore'
+import { computed } from 'vue'
+const immunityStore = useImmunityStore()
+const { immunityList } = storeToRefs(immunityStore)
+interface Props {
+  id: string
+  unreadCount?: number
+  img?: string
+  username?: string
+  text?: string
+  active?: boolean
+  top?: boolean
+  rightEvent?: (chatId: string, event: MouseEvent) => void
+  lastTime?: number
+  isGroup?: boolean
+}
+
+const props = defineProps<Props>()
+const showNotice = computed(() => {
+  return immunityList.value.includes(props.id)
+})
+</script>
+
+<style scoped lang="less">
+.item {
+  height: 5.5rem;
+  display: flex;
+  position: relative;
+  padding-left: 8px;
+  align-items: center;
+
+  .close {
+    position: absolute;
+    width: 1.5rem;
+    height: 1.5rem;
+    right: 15px;
+    top: 1.825rem;
+    display: none;
+  }
+
+  .top {
+    position: absolute;
+    width: 1.5rem;
+    height: 1.5rem;
+    right: 35px;
+    top: 1.825rem;
+    display: none;
+  }
+
+  &:hover {
+    .close {
+      display: block;
+    }
+
+    .top {
+      display: block;
+    }
+  }
+
+  .avatar {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    width: 4.4rem;
+    height: 4.4rem;
+  }
+
+  .text {
+    margin-left: 8px;
+    flex: 3;
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    flex-shrink: 0;
+    overflow: hidden;
+
+    & > div {
+      display: flex;
+      justify-content: flex-start;
+      align-items: center;
+      flex: 1;
+
+      & > div {
+        display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        color: #999;
+        font-size: 12px;
+      }
+    }
+  }
+}
+.item:hover {
+  background-color: #dfdfdf;
+}
+.online-img {
+  width: 16px;
+  height: 16px;
+  margin-right: 10px;
+}
+
+.active {
+  background-color: #ccc !important;
+}
+.top {
+  background-color: #dfdfdf;
+}
+
+.grey {
+  filter: grayscale(100%);
+}
+
+.last-time {
+  width: 60px;
+  text-align: center;
+  font-size: 10px;
+  color: #999;
+}
+</style>

+ 42 - 0
src/renderer/src/components/ChatMessageUser.vue

@@ -0,0 +1,42 @@
+<template>
+  <div v-if="user">
+    <vim-avatar
+      :img="user.avatar"
+      :name="user.name"
+      @contextmenu="groupChatUserRightEvent(user.name, true, atCallBack, $event)"
+    />
+    <div v-if="message.fromId === current?.id" class="message-info right">
+      <i>
+        <vim-time :time="message.timestamp" />
+      </i>
+      <span>{{ user.name }}</span>
+    </div>
+    <div v-if="message.fromId !== current?.id" class="message-info">
+      <span>{{ user.name }}</span>
+      <i>
+        <vim-time :time="message.timestamp" />
+      </i>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import { computed } from 'vue'
+import VimTime from '@renderer/components/VimTime.vue'
+import { useUserStore } from '@renderer/store/userStore'
+import Message from '@renderer/mode/Message'
+import VimAvatar from '@renderer/components/VimAvatar.vue'
+import groupChatUserRightEvent from '../hooks/useGroupChatUserRightEvent'
+
+const userStore = useUserStore()
+const current = userStore.getUser()
+
+interface Props<T> {
+  message: T
+  atCallBack?: (item: string) => void
+}
+
+const props = defineProps<Props<Message>>()
+const user = computed(() => useUserStore().getMapUser(props.message.fromId))
+
+</script>
+<style scoped lang="less"></style>

+ 123 - 0
src/renderer/src/components/ChatsRadio.vue

@@ -0,0 +1,123 @@
+<template>
+  <div style="margin-bottom: 15px">
+    <el-input v-model="keyword" placeholder="搜索"></el-input>
+  </div>
+  <el-row :gutter="10">
+    <el-col :span="12">
+      <el-checkbox-group v-model="itemChecked" @change="change">
+        <div
+          v-for="chat in keywordFilter([...itemList.values()], keyword)"
+          :key="chat.id"
+          style="height: 60px"
+        >
+          <el-checkbox :value="chat">
+            <template #default>
+              <div class="check-item">
+                <div class="check-item-avatar">
+                  <vim-avatar :img="chat.avatar" :name="chat.name" />
+                </div>
+                <div class="check-item-name">
+                  {{ chat.name }}
+                </div>
+              </div>
+            </template>
+          </el-checkbox>
+        </div>
+      </el-checkbox-group>
+    </el-col>
+    <el-col :span="12">
+      <div v-for="chat in itemChecked" :key="chat.id" style="height: 60px">
+        <div class="check-item">
+          <div class="check-item-avatar">
+            <vim-avatar :img="chat.avatar" :name="chat.name" />
+          </div>
+          <div class="check-item-name">
+            {{ chat.name }}
+          </div>
+        </div>
+      </div>
+    </el-col>
+  </el-row>
+</template>
+
+<script setup lang="ts">
+import { storeToRefs } from 'pinia'
+import { ref } from 'vue'
+import VimAvatar from '@renderer/components/VimAvatar.vue'
+import User from '@renderer/mode/User'
+import Chat from '@renderer/mode/Chat'
+import keywordFilter from '@renderer/utils/PinYinUtils'
+import Group from '@renderer/mode/Group'
+import ChatSimple from '@renderer/mode/ChatSimple'
+import ChatType from '@renderer/utils/ChatType'
+import { useFriendStore } from '@renderer/store/friendStore'
+import { useGroupStore } from '@renderer/store/groupStore'
+import { useChatStore } from '@renderer/store/chatStore'
+
+const emit = defineEmits(['set-data'])
+const chatStore = useChatStore()
+const keyword = ref('')
+//选中的chats
+const itemChecked = ref([])
+//好友
+const itemList = ref(new Map<string, ChatSimple>())
+//好友
+const { friendList } = storeToRefs(useFriendStore())
+//群
+const { groupList } = storeToRefs(useGroupStore())
+// checkbox 变化
+const change = () => {
+  emit('set-data', itemChecked.value)
+}
+
+chatStore.chats.forEach((item: Chat) => {
+  itemList.value.set(item.id, {
+    id: item.id,
+    name: item.name,
+    avatar: item.avatar,
+    type: item.type
+  })
+})
+friendList.value.forEach((item: User) => {
+  itemList.value.set(item.id, {
+    id: item.id,
+    name: item.name,
+    avatar: item.avatar,
+    type: ChatType.FRIEND
+  })
+})
+groupList.value.forEach((item: Group) => {
+  itemList.value.set(item.id, {
+    id: item.id,
+    name: item.name,
+    avatar: item.avatar,
+    type: ChatType.GROUP
+  })
+})
+</script>
+
+<style lang="less" scoped>
+.check-item {
+  display: flex;
+
+  .check-item-avatar {
+    flex: 2;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .avatar {
+      height: 40px;
+      width: 40px;
+    }
+  }
+
+  .check-item-name {
+    flex: 6;
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
+    padding-left: 15px;
+  }
+}
+</style>

+ 102 - 0
src/renderer/src/components/FileUpload.vue

@@ -0,0 +1,102 @@
+<template>
+  <el-upload
+    :action="`${host}/${VimConfig.uploadType}/upload`"
+    :headers="headers"
+    :show-file-list="false"
+    :on-success="handleSuccess"
+    :on-error="handleError"
+    :before-upload="beforeUpload"
+    style="display: inline-block"
+  >
+    <slot></slot>
+  </el-upload>
+</template>
+
+<script setup lang="ts">
+import { PropType, reactive, toRefs } from 'vue'
+import FetchRequest from '@renderer/api/FetchRequest'
+import Auth from '@renderer/api/Auth'
+import { ElLoading, ElMessage } from 'element-plus'
+import MessageType from '@renderer/utils/MessageType'
+import Extend from '@renderer/mode/Extend'
+import VimConfig from '../config/VimConfig'
+
+interface VimData {
+  host: string
+  headers: any
+}
+
+const props = defineProps({
+  fileTypes: {
+    type: Object as PropType<Array<string>>,
+    required: true,
+    default: null
+  },
+  mType: {
+    type: String,
+    required: true,
+    default: null
+  }
+})
+const beforeUpload = (file: File) => {
+  const suffix = file.name.substring(file.name.lastIndexOf('.') + 1)
+  const suffixes = props.fileTypes
+  const len = suffixes.filter((item) => {
+    return item === suffix.toLowerCase()
+  }).length
+  if (len === 0) {
+    ElMessage.error('不支持的文件类型,仅支持:' + suffixes.join(','))
+    return false
+  }
+  const size = file.size
+  if (size > 1024 * 1024 * 10) {
+    ElMessage.error('文件大小不能超过10M')
+    return false
+  }
+  ElLoading.service({
+    fullscreen: true,
+    text: '上传中...'
+  })
+  return true
+}
+const vimData = reactive<VimData>({
+  host: FetchRequest.getHost(),
+  headers: {
+    'Access-Control-Allow-Origin': '*',
+    Authorization: 'Bearer ' + Auth.getToken()
+  }
+})
+const emits = defineEmits(['uploadSuccess'])
+/**
+ * 上传成功回调
+ * @param res 结果
+ */
+const handleSuccess = (res: UploadResult) => {
+  ElLoading.service().close()
+  emits('uploadSuccess', getByMessageType(res), props.mType)
+}
+
+const handleError = () => {
+  ElLoading.service().close()
+  ElMessage.error('上传失败')
+}
+interface UploadResult {
+  url: string
+  fileName?: string
+  originalFilename?: string
+}
+
+const getByMessageType = (res: UploadResult): Extend => {
+  switch (props.mType) {
+    case MessageType.file:
+      return { url: res.url, fileName: res.originalFilename }
+    case MessageType.video:
+      return { url: res.url, fileName: res.originalFilename }
+    default:
+      return { url: res.url }
+  }
+}
+const { host, headers } = toRefs(vimData)
+</script>
+
+<style scoped></style>

+ 176 - 0
src/renderer/src/components/HistoryMessage.vue

@@ -0,0 +1,176 @@
+<template>
+  <div>
+    <el-form :inline="true">
+      <el-form-item>
+        <el-date-picker
+          v-model="dateRange"
+          style="width: 200px"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始时间"
+          end-placeholder="结束时间"
+          size="small"
+          value-format="YYYY-MM-DD"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-select v-model="messageType" placeholder="类型" size="small" style="width: 70px">
+          <el-option
+            v-for="item in options"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-input
+          v-model="keyword"
+          size="small"
+          style="width: 100px"
+          placeholder="请输入搜索内容"
+        ></el-input>
+      </el-form-item>
+      <el-form-item>
+        <el-button size="small" type="primary" @click="change(1)">查询</el-button>
+      </el-form-item>
+    </el-form>
+  </div>
+  <div class="im-chat-main">
+    <div id="his-chat-message" class="im-chat-main-box messages" style="height: 100%">
+      <ul>
+        <li
+          v-for="(item, index) in hisMessageList"
+          :key="index"
+          :class="{ 'im-chat-mine': item.fromId === currentUser?.id }"
+        >
+          <chat-message-user
+            v-if="item.messageType !== MessageType.event"
+            class="im-chat-user"
+            :message="item"
+          />
+          <component :is="useMessageComponent(item.messageType)" :message="item"></component>
+        </li>
+      </ul>
+    </div>
+  </div>
+  <el-pagination
+    v-model:currentPage="current"
+    background
+    :page-size="size"
+    layout="prev, pager, next"
+    :total="total"
+    @current-change="change"
+  >
+  </el-pagination>
+</template>
+
+<script setup lang="ts">
+import { nextTick, reactive, ref, toRefs, watch } from 'vue'
+import MessageApi from '@renderer/api/MessageApi'
+import ChatMessageUser from '@renderer/components/ChatMessageUser.vue'
+import ChatUtils from '@renderer/utils/ChatUtils'
+import { useUserStore } from '@renderer/store/userStore'
+import Message from '@renderer/mode/Message'
+import useMessageComponent from '@renderer/hooks/useMessageComponent'
+import UserSimple from '@renderer/mode/UserSimple'
+import MessageType from '../utils/MessageType'
+
+const options = [
+  { value: '', label: '全部' },
+  { value: '0', label: '文本' },
+  { value: '1', label: '图片' },
+  { value: '2', label: '文件' },
+  { value: '3', label: '语音' }
+]
+const userStore = useUserStore()
+const dateRange = ref([])
+const keyword = ref('')
+const messageType = ref('')
+const size = ref(100)
+const currentUser = userStore.getUser()
+interface Props {
+  chatUsers: Map<string, UserSimple>
+  chatId: string
+  fromId: string
+  type: string
+  showHistory: boolean
+}
+const props = defineProps<Props>()
+interface IData {
+  hisMessageList: Message[]
+  current: number
+  total: number
+}
+const data = reactive<IData>({
+  hisMessageList: [],
+  current: 1,
+  total: 0
+})
+
+const change = (current: number) => {
+  MessageApi.page(
+    props.chatId,
+    props.fromId,
+    keyword.value,
+    props.type,
+    messageType.value,
+    current,
+    dateRange.value ? dateRange.value[0] : '',
+    dateRange.value ? dateRange.value[1] : '',
+    size.value
+  ).then((res) => {
+    data.hisMessageList = res.data.records.reverse()
+    data.total = res.data.total
+    data.current = current
+    nextTick(() => {
+      ChatUtils.imageLoad('his-chat-message')
+    })
+  })
+}
+
+watch(
+  () => {
+    return props.showHistory
+  },
+  (n) => {
+    if (n) {
+      change(1)
+    }
+  },
+  {
+    immediate: true
+  }
+)
+const { hisMessageList, current, total } = toRefs(data)
+</script>
+
+<style scoped>
+.im-chat-main {
+  height: calc(100% - 90px);
+  overflow-y: hidden;
+  overflow-x: hidden;
+  margin-bottom: 10px;
+}
+.im-chat-main .messages {
+  width: 100%;
+  height: calc(100% - 3rem);
+  overflow-y: scroll;
+  overflow-x: hidden;
+}
+
+.im-chat-main-box {
+  flex: 1;
+  padding: 1rem 1rem 0 1rem;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+.im-chat-event {
+  margin-top: 10px;
+  margin-left: 0;
+  width: calc(100% + 60px);
+  font-size: 12px;
+  text-align: center;
+  color: #999;
+}
+</style>

+ 117 - 0
src/renderer/src/components/MessageForward.vue

@@ -0,0 +1,117 @@
+<template>
+  <div class="im-chat-multiple">
+    <div class="close">
+      <i class="iconfont icon-v-close" @click="handleCloseMultiple"></i>
+    </div>
+    <el-row :gutter="20">
+      <el-col :span="8">
+        <div class="im-chat-multiple-item" @click="sendMultiple">
+          <el-avatar :icon="Share" />
+        </div>
+        <div class="t-center">合并转发</div>
+      </el-col>
+      <el-col :span="8">
+        <div class="im-chat-multiple-item" @click="sendSingle">
+          <el-avatar :icon="TopRight" />
+        </div>
+        <div class="t-center">逐条转发</div>
+      </el-col>
+      <el-col :span="8">
+        <div class="im-chat-multiple-item" @click="storeMessage">
+          <el-avatar :icon="StarFilled" />
+        </div>
+        <div class="t-center">收藏</div>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+<script setup lang="ts">
+import { useMessageStore } from '../store/messageStore'
+import { Share, StarFilled, TopRight } from '@element-plus/icons-vue'
+import Message from '../mode/Message'
+import showForward from './forward'
+import MessageType from '../utils/MessageType'
+import { storeToRefs } from 'pinia'
+import { ElMessage } from 'element-plus'
+import Collect from '../mode/Collect'
+import CollectApi from '../api/CollectApi'
+import ChatType from '../utils/ChatType'
+import { useUserStore } from '../store/userStore'
+const messageStore = useMessageStore()
+const userStore = useUserStore()
+const { checkList } = storeToRefs(messageStore)
+/**
+ * 关闭多选
+ */
+const handleCloseMultiple = () => {
+  messageStore.setCheckWidth('0')
+  messageStore.setCheckList([])
+}
+
+/**
+ * 合并转发
+ */
+const sendMultiple = (): void => {
+  if (checkList.value.length > 0) {
+    const message0 = checkList.value[0]
+    const fromId = message0?.fromId
+    const toId = message0?.chatId
+    console.log('message0', message0)
+    let showName = '群聊'
+    if (message0.type === ChatType.FRIEND && fromId && toId) {
+      showName = userStore.getMapUser(fromId)?.name + '和' + userStore.getMapUser(toId)?.name
+    }
+    const message: Message = {
+      messageType: MessageType.forward,
+      content: `${showName}的聊天记录`,
+      chatId: '',
+      fromId: '',
+      type: '',
+      mine: false,
+      timestamp: new Date().getTime(),
+      extend: {
+        messageList: checkList.value
+      }
+    }
+    //按照id排序checkList.value
+    checkList.value.sort((a, b) => a!.id - b!.id)
+    showForward([message])
+    messageStore.setCheckList([])
+  } else {
+    ElMessage.warning('请选择转发的消息')
+  }
+}
+
+/**
+ * 逐条转发
+ */
+const sendSingle = (): void => {
+  if (checkList.value.length > 0) {
+    showForward([...checkList.value])
+    messageStore.setCheckList([])
+  } else {
+    ElMessage.warning('请选择转发的消息')
+  }
+}
+
+const storeMessage = (): void => {
+  checkList.value.forEach((message) => {
+    const collect: Collect = {
+      fromId: message.fromId,
+      content: message.content,
+      messageType: message.messageType,
+      extend: message.extend == null ? '' : JSON.stringify(message.extend),
+      sendTime: message.timestamp
+    }
+    CollectApi.save(collect)
+  })
+  messageStore.setCheckList([])
+  handleCloseMultiple()
+  ElMessage.success('收藏成功')
+}
+</script>
+<style scoped lang="less">
+.t-center {
+  text-align: center;
+}
+</style>

+ 92 - 0
src/renderer/src/components/QuoteMessage.vue

@@ -0,0 +1,92 @@
+<template>
+  <div v-if="quoteMessage && user" class="message-box" @click="scrollTo">
+    <div
+      v-if="quoteMessage.messageType === MessageType.text"
+      class="message"
+      :title="ChatUtils.transformXss(quoteMessage.content)"
+    >
+      <b>{{ user.name }}:</b>
+      <div style="display: inline" v-html="ChatUtils.transformXss(quoteMessage.content)"></div>
+    </div>
+    <div v-if="quoteMessage.messageType === MessageType.image && quoteMessage.extend?.url" class="message-img">
+      <b>{{ user.name }}:</b>
+      <img alt="图片" :src="quoteMessage.extend.url" style="height: 30px" />
+    </div>
+    <div v-if="quoteMessage.messageType === MessageType.file && quoteMessage.extend?.fileName" class="message">
+      <b>{{ user.name }}:</b>{{ getFileName(quoteMessage.extend.fileName) }}
+    </div>
+    <div v-if="quoteMessage.messageType === MessageType.video && quoteMessage.extend?.url" class="message-img">
+      <b>{{ user.name }}:</b>
+      <video style="height: 30px">
+        <source :src="quoteMessage.extend.url" type="video/mp4" />
+      </video>
+    </div>
+    <div v-if="quoteMessage.messageType === MessageType.voice && quoteMessage.extend?.time" class="message">
+      <div
+        :style="{
+          width: quoteMessage.extend.time > 40 ? '50%' : quoteMessage.extend.time + 10 + '%'
+        }"
+      >
+        <div>
+          <b>{{ user.name }}:</b>
+          <i class="iconfont icon-v-voice" :class="{ 'icon-v-voice-right': quoteMessage.mine }"></i>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import { ref } from 'vue'
+import MessageType from '../utils/MessageType'
+import { storeToRefs } from 'pinia'
+import { useUserStore } from '../store/userStore'
+import Message from '../mode/Message'
+import ChatUtils from '../utils/ChatUtils'
+import getFileName from '../utils/FileUtils'
+import UserSimple from '../mode/UserSimple'
+
+const { userMap } = storeToRefs(useUserStore())
+interface IProps {
+  message: Message
+}
+const props = defineProps<IProps>()
+const quoteMessage = ref<Message | undefined>(props.message.extend?.quoteMessage)
+const user = ref<UserSimple>()
+if (quoteMessage.value) {
+  user.value = userMap.value.get(quoteMessage.value.fromId)
+}
+const scrollTo = () => {
+  ChatUtils.scrollTo('message-box', `m-${quoteMessage.value?.id}`)
+}
+</script>
+<style scoped lang="less">
+.message-box {
+  cursor: pointer;
+  flex: 1;
+  overflow: hidden;
+  .message {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    line-height: 30px;
+    white-space: nowrap;
+    display: inline-block;
+    background-color: #eee;
+    padding: 0 11px;
+    max-width: 620px;
+    img {
+      vertical-align: middle;
+    }
+  }
+  .message-img {
+    line-height: 30px;
+    white-space: nowrap;
+    display: inline-flex;
+    background-color: #eee;
+    padding: 0 11px;
+    max-width: 620px;
+    img {
+      vertical-align: middle;
+    }
+  }
+}
+</style>

+ 110 - 0
src/renderer/src/components/SearchFriend.vue

@@ -0,0 +1,110 @@
+<template>
+  <el-dialog v-model="show" title="添加好友" width="400px" :before-close="handleClose">
+    <div>
+      <el-form-item label="">
+        <el-input v-model="mobile" placeholder="请输入用户名或手机号" class="input-with-select">
+          <template #append>
+            <el-button @click="search">查找</el-button>
+          </template>
+        </el-input>
+      </el-form-item>
+      <div
+        v-for="(user, index) in users"
+        :key="index"
+        class="solid"
+        :class="checkedUserId === user.id ? 'active' : ''"
+        @click="check(user)"
+      >
+        <chat-item
+          :id="user.id"
+          :img="user.avatar"
+          :username="user.name"
+          :show-del="false"
+        ></chat-item>
+      </div>
+    </div>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="close">取消</el-button>
+        <el-button type="primary" @click="add">确定</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import UserApi from '@renderer/api/UserApi'
+import ChatItem from '@renderer/components/ChatItem.vue'
+import User from '@renderer/mode/User'
+import { ElMessage } from 'element-plus/es'
+import addFriend from '@renderer/components/add-friend-modal/index'
+
+const emit = defineEmits(['close'])
+const mobile = ref('')
+const users = ref<Array<User>>([])
+const props = defineProps({
+  dialogVisible: {
+    type: Boolean,
+    required: true,
+    default: false
+  }
+})
+
+const handleClose = () => {
+  mobile.value = ''
+  emit('close')
+}
+
+const show = computed(() => {
+  return props.dialogVisible
+})
+
+const reset = () => {
+  mobile.value = ''
+  users.value = []
+}
+
+const checkedUserId = ref<string>('')
+
+const check = (user: User) => {
+  checkedUserId.value = user.id
+}
+const close = () => {
+  reset()
+  emit('close')
+}
+const search = () => {
+  const text = mobile.value.trim()
+  if (text && text != '') {
+    UserApi.search(mobile.value.trim()).then((res) => {
+      users.value = res.data
+      if (res.data.length === 0) {
+        ElMessage.info('未找到好友信息')
+      }
+    })
+  } else {
+    ElMessage.info('请输出查询条件')
+  }
+}
+
+const add = () => {
+  if (checkedUserId.value !== '') {
+    close()
+    addFriend(checkedUserId.value)
+  } else {
+    ElMessage.error('请选择一个用户')
+  }
+}
+</script>
+<style scoped>
+.solid {
+  border: 1px solid #eeeeee;
+  padding-top: 10px;
+}
+
+.solid.active {
+  border: 1px solid #1d86f1;
+  padding-top: 10px;
+}
+</style>

+ 16 - 0
src/renderer/src/components/UserNameTag.vue

@@ -0,0 +1,16 @@
+<template>
+  <el-tag @click="showUser(props.id, false)">{{ user?.name }}</el-tag>
+</template>
+
+<script setup lang="ts">
+import { useUserStore } from '@renderer/store/userStore'
+import showUser from '@renderer/components/user-modal/index'
+import { computed } from 'vue'
+interface IProps<T> {
+  id: T
+}
+const props = defineProps<IProps<string>>()
+const user = computed(() => useUserStore().getMapUser(props.id))
+</script>
+
+<style scoped></style>

+ 103 - 0
src/renderer/src/components/VimAvatar.vue

@@ -0,0 +1,103 @@
+<template>
+  <div class="avatar-box">
+    <el-avatar
+      v-if="url"
+      :class="{ 'grid-image': isGroup }"
+      shape="square"
+      :size="size"
+      :src="url"
+      fit="cover"
+    ></el-avatar>
+    <el-avatar
+      v-if="!url"
+      shape="square"
+      :size="size"
+      fit="cover"
+      :class="{ 'grid-image': isGroup }"
+    >
+      <template #default>
+        <span style="font-size: 1.5rem">{{ start }}</span>
+      </template>
+    </el-avatar>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, withDefaults } from 'vue'
+import FetchRequest from '@renderer/api/FetchRequest'
+
+const host = ref(FetchRequest.getHost())
+interface IProps {
+  size?: 'default' | 'large' | 'small' | number
+  img?: string
+  name?: string
+  isGroup?: boolean
+}
+const props = withDefaults(defineProps<IProps>(), {
+  size: 'default' as 'default' | 'large' | 'small' | number,
+  img: '',
+  name: '',
+  isGroup: false
+})
+const url = computed(() => {
+  if (props.img?.indexOf('http') > -1) {
+    return props.img
+  } else if (props.img) {
+    return host.value + props.img
+  } else {
+    return undefined
+  }
+})
+const start = computed(() => {
+  return props.name ? props.name.slice(0, 2) : ''
+})
+</script>
+<style lang="less" scoped>
+.avatar {
+  width: 6rem;
+  height: 6rem;
+}
+.avatar-box {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.grid-image {
+  position: relative;
+  overflow: hidden; /* 防止裁剪部分之外的内容显示出来 */
+}
+
+.grid-image img {
+  width: 100%;
+  height: 100%;
+  display: block;
+  position: relative;
+  z-index: 1;
+}
+
+.grid-image::before,
+.grid-image::after {
+  content: '';
+  position: absolute;
+  z-index: 2;
+  background: rgba(255, 255, 255, 0.5); /* 根据需要调整蒙板颜色 */
+}
+
+.grid-image::before {
+  top: 0;
+  left: 0;
+  right: calc(50% - 1px);
+  bottom: calc(50% - 1px);
+  border-right: 1px solid /* 边框颜色 */;
+  border-bottom: 1px solid /* 边框颜色 */;
+}
+
+.grid-image::after {
+  top: 50%;
+  left: 50%;
+  right: 0;
+  bottom: 0;
+  border-left: 1px solid /* 边框颜色 */;
+  border-top: 1px solid /* 边框颜色 */;
+}
+</style>

+ 45 - 0
src/renderer/src/components/VimFaces.vue

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <ul class="faces">
+      <li v-for="(item, index) in faceList" :key="index">
+        <img :src="faceMap.get(item)" :alt="item" :title="item" @click="insertFace(item)" />
+      </li>
+    </ul>
+    <div class="clear"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import FaceUtils from '@renderer/utils/FaceUtils'
+import { ref } from 'vue'
+
+const faceList = ref(FaceUtils.alt)
+const faceMap = ref(FaceUtils.faces())
+const emit = defineEmits(['insertFace'])
+const insertFace = (item: string) => {
+  emit('insertFace', item)
+}
+</script>
+
+<style scoped lang="less">
+.faces {
+  width: 30.5rem;
+  list-style: none;
+  background-color: #ffffff;
+  border: 1px solid #f0f5ff;
+  display: block;
+  height: 25rem;
+  & > li {
+    width: 3rem !important;
+    height: 3rem !important;
+    display: inline-block;
+    padding: 4px;
+    float: left;
+    cursor: pointer;
+    & > img {
+      width: 100%;
+      height: 100%;
+    }
+  }
+}
+</style>

+ 49 - 0
src/renderer/src/components/VimTime.vue

@@ -0,0 +1,49 @@
+<template>
+  <text>
+    {{ formatDate }}
+  </text>
+</template>
+
+<script lang="ts" setup>
+import { format, formatDistanceToNow } from 'date-fns'
+import { zhCN } from 'date-fns/locale'
+import { computed } from 'vue'
+
+// 定义props
+const props = defineProps({
+  time: {
+    type: [Number, Date, String],
+    default: new Date()
+  }
+})
+
+/**
+ * 格式化时间
+ */
+const formatDate = computed(() => {
+  const type = typeof props.time
+  let time
+  if (type === 'number') {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    //@ts-ignore
+    const timestamp = props.time.toString().length > 10 ? props.time : props.time * 1000
+    time = new Date(timestamp).getTime()
+  } else if (type === 'object') {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    //@ts-ignore
+    time = props.time.getTime()
+  } else if (type === 'string') {
+    time = new Date(props.time).getTime()
+  }
+  if (new Date().getTime() - time > 1000 * 60 * 60 * 24 * 3) {
+    return format(time, 'yyyy-MM-dd HH:mm')
+  } else {
+    return formatDistanceToNow(time, {
+      locale: zhCN,
+      addSuffix: true
+    })
+  }
+})
+</script>
+
+<style></style>

+ 71 - 0
src/renderer/src/components/VimTop.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="im-top" style="-webkit-app-region: drag">
+    <a href="javascript:void(0)" style="-webkit-app-region: no-drag" @click="min()">
+      <i class="iconfont icon-v-24gl-minimization"></i>
+    </a>
+    <a href="javascript:void(0)" style="-webkit-app-region: no-drag" @click="max()">
+      <i class="iconfont" :class="icon"></i>
+    </a>
+    <a href="javascript:void(0)" style="-webkit-app-region: no-drag" @click="close()">
+      <i class="iconfont icon-v-guanbi1"></i>
+    </a>
+  </div>
+</template>
+<script setup lang="ts">
+import { getCurrentInstance, ref } from 'vue'
+
+const { proxy } = getCurrentInstance()
+function min() {
+  proxy.$winControl.min()
+}
+const iconBig = 'icon-v-yk_fangkuai'
+const iconSmall = 'icon-v-xiaoxitixingchuangkoudanchufangshi'
+const icon = ref(iconBig)
+
+function max() {
+  proxy.$winControl.max()
+  icon.value = icon.value === iconBig ? iconSmall : iconBig
+}
+
+function close() {
+  proxy.$winControl.close()
+}
+</script>
+<style lang="less" scoped>
+@import '../assets/styles/theme.less';
+.im-top {
+  height: 3rem;
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  z-index: 2;
+  right: 0;
+  width: 100%;
+
+  a {
+    display: inline-block;
+    color: #ffffff;
+    text-decoration: none;
+    padding: 10px;
+
+    i {
+      color: #666666;
+      font-size: 1.4rem;
+      font-weight: bolder;
+    }
+
+    :hover {
+      background-color: #dddddd;
+    }
+
+    .text-right {
+      float: right;
+      width: 2.4rem;
+      height: 2.4rem;
+      display: inline-block;
+      padding: 0.5rem;
+      text-align: center;
+    }
+  }
+}
+</style>

+ 124 - 0
src/renderer/src/components/add-friend-modal/AddFriendModal.vue

@@ -0,0 +1,124 @@
+<template>
+  <el-dialog v-model="open" width="40rem" center :show-close="false" :close-on-click-modal="false">
+    <div v-if="friend" class="info">
+      <vim-avatar :img="friend.avatar" :name="friend.name" />
+      <div>{{ friend.name }}</div>
+      <el-form-item v-if="userSetting.addFriendValidate">
+        <el-input v-model="message" type="textarea" placeholder="验证消息" />
+      </el-form-item>
+    </div>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="closeDialog">取消</el-button>
+        <el-button type="primary" @click="add()">确定</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import UserApi from '@renderer/api/UserApi'
+import SettingApi from '@renderer/api/SettingApi'
+import User from '@renderer/mode/User'
+import VimAvatar from '@renderer/components/VimAvatar.vue'
+import FriendApi from '@renderer/api/FriendApi'
+import { ElMessage } from 'element-plus'
+import DictUtils from '@renderer/utils/DictUtils'
+import { useUserStore } from '@renderer/store/userStore'
+import { useFriendStore } from '@renderer/store/friendStore'
+import { useChatStore } from '@renderer/store/chatStore'
+import ChatType from '../../utils/ChatType'
+
+const open = ref(true)
+const friend = ref<User>()
+const message = ref<string>('')
+const userSetting = reactive({
+  canAddFriend: false,
+  addFriendValidate: false,
+  canSendMessage: false,
+  canSoundRemind: false,
+  canVoiceRemind: false
+})
+defineEmits(['close'])
+
+const props = defineProps({
+  friendId: {
+    type: String,
+    required: true,
+    default: null
+  },
+  closeDialog: {
+    type: Function,
+    default: null
+  }
+})
+
+onMounted(() => {
+  UserApi.getUser(props.friendId)
+    .then((res) => {
+      friend.value = res.data
+      return SettingApi.get(props.friendId)
+    })
+    .then((res) => {
+      const setting_ = res.data
+      userSetting.canAddFriend = setting_.canAddFriend === DictUtils.YES
+      userSetting.addFriendValidate = setting_.addFriendValidate === DictUtils.YES
+      userSetting.canSendMessage = setting_.canSendMessage === DictUtils.YES
+      userSetting.canSoundRemind = setting_.canSoundRemind === DictUtils.YES
+      userSetting.canVoiceRemind = setting_.canVoiceRemind === DictUtils.YES
+    })
+})
+
+/**
+ * 添加好友
+ */
+const add = () => {
+  FriendApi.add({
+    friendId: props.friendId,
+    userId: useUserStore().getUser()?.id,
+    message: message.value
+  }).then((res) => {
+    if (res.msg === '添加成功') {
+      useFriendStore().loadData()
+      if (friend.value) {
+        useChatStore().openChat({
+          id: props.friendId,
+          type: ChatType.FRIEND,
+          name: friend.value.name,
+          avatar: friend.value.avatar,
+          lastMessage: '',
+          isLoading: false,
+          unreadCount: 0,
+          loaded: false
+        })
+      }
+      ElMessage.success('添加成功')
+    } else {
+      ElMessage.warning(res.msg)
+    }
+    //通知对方
+    useFriendStore().notifyFlushFriendStore(props.friendId)
+    closeDialog()
+  })
+}
+
+/**
+ * sss
+ */
+const closeDialog = () => {
+  props.closeDialog()
+}
+</script>
+
+<style scoped lang="less">
+.info {
+  text-align: center;
+  line-height: 200%;
+}
+
+.description {
+  padding: 20px 20px 0px 20px;
+  background-color: #ffffff;
+}
+</style>

+ 25 - 0
src/renderer/src/components/add-friend-modal/index.ts

@@ -0,0 +1,25 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import AddFriendModal from '@renderer/components/add-friend-modal/AddFriendModal.vue'
+import router from '@renderer/router'
+
+/**
+ * 函数方式调用添加好友
+ * @param friendId friendId
+ */
+const addFriend = (friendId: string): void => {
+  const instance = createApp(AddFriendModal, { friendId, closeDialog })
+  instance.use(router)
+  // 使用element-plus 并且设置全局的大小
+  instance.use(ElementPlus)
+  const node = document.createElement('div')
+  document.body.appendChild(node)
+  instance.mount(node)
+
+  function closeDialog() {
+    instance.unmount()
+    document.body.removeChild(node)
+  }
+}
+
+export default addFriend

+ 134 - 0
src/renderer/src/components/at-menu/AtMenu.vue

@@ -0,0 +1,134 @@
+<template>
+  <ul class="table-right-menu">
+    <!-- 循环菜单项,事件带参数抛出 -->
+    <li
+      v-for="item in keywordFilter(rightClickInfo?.menuList)"
+      :key="item.btnName"
+      class="table-right-menu-item"
+      @click.stop="fnHandler(item)"
+    >
+      <div class="table-right-menu-item-btn">
+        <span>{{ item.btnName }}</span>
+      </div>
+    </li>
+  </ul>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue'
+import RightClickMenu from '@renderer/mode/RightClickMenu'
+import { storeToRefs } from 'pinia'
+import { useAtStore } from '@renderer/store/AtStore'
+import { match } from 'pinyin-pro'
+
+const atStore = useAtStore()
+const { keyword } = storeToRefs(atStore)
+const props = defineProps({
+  // 接收右键点击的信息
+  rightClickInfo: {
+    type: Object,
+    default: () => {
+      return {
+        position: {
+          // 右键点击的位置
+          x: null,
+          y: null
+        },
+        menuList: new Array<RightClickMenu>()
+      }
+    }
+  },
+  // 重要参数,用于标识是哪个右键菜单dom元素
+  classIndex: {
+    type: Number,
+    default: 0
+  },
+  closeRightMenu: {
+    type: Function,
+    default: () => {
+      console.log()
+    }
+  }
+})
+const hide = (e: MouseEvent) => {
+  //鼠标左键点击隐藏右键菜单
+  if (e.button === 0) {
+    //防止先执行
+    setTimeout(() => {
+      props.closeRightMenu()
+      atStore.setKeyword('')
+    }, 0)
+  }
+}
+const fnHandler = (item: any) => {
+  item.fn()
+  props.closeRightMenu()
+}
+
+const keywordFilter = (items: RightClickMenu[]): RightClickMenu[] => {
+  return items.filter((item) => {
+    return !!(keyword.value.trim() === '' || match(item.btnName, keyword.value))
+  })
+}
+
+onMounted(() => {
+  const x = props.rightClickInfo?.position.x // 获取x轴坐标
+  const y = props.rightClickInfo?.position.y // 获取y轴坐标
+  const innerWidth = window.innerWidth // 获取页面可是区域宽度,即页面的宽度
+  const innerHeight = window.innerHeight // 获取可视区域高度,即页面的高度
+  /**
+   * 注意,这里要使用getElementsByClassName去选中对应dom,因为右键菜单组件可能被多处使用
+   * classIndex标识就是去找到对应的那个右键菜单组件的,需要加的
+   * */
+  const menu: HTMLElement = document.getElementsByClassName('table-right-menu')[
+    props.classIndex
+  ] as HTMLElement
+  menu.style.display = 'block'
+  let menuHeight = props.rightClickInfo.menuList.length * 30 // 菜单容器高
+  menuHeight = menuHeight > 180 ? 180 : menuHeight
+  const menuWidth = 180 // 菜单容器宽
+  // 菜单的位置计算
+  menu.style.height = menuWidth + 'px'
+  menu.style.top = (y + menuHeight > innerHeight ? y - menuHeight : y) + 'px'
+  menu.style.left = (x + menuWidth > innerWidth ? innerWidth - menuWidth : x) + 'px'
+  // 因为菜单还要关闭,就绑定一个鼠标点击事件,通过e.button判断点击的是否是左键,左键关闭菜单
+  document.addEventListener('mouseup', hide, false)
+})
+</script>
+
+<style lang="less" scoped>
+.table-right-menu {
+  color: #333;
+  background: #fff;
+  border-radius: 4px;
+  list-style-type: none;
+  box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
+  font-size: 12px;
+  font-weight: 500;
+  box-sizing: border-box;
+  padding: 4px 0;
+  // 固定定位,抬高层级,初始隐藏,右击时置为display:block显示
+  position: fixed;
+  z-index: 3000;
+  max-height: 180px;
+  width: 120px;
+  overflow-y: scroll;
+  //display: none;
+  .table-right-menu-item {
+    box-sizing: border-box;
+    padding: 6px 12px;
+    border-radius: 4px;
+    transition: all 0.36s;
+    cursor: pointer;
+    .table-right-menu-item-btn {
+      .iii {
+        margin-right: 4px;
+      }
+    }
+  }
+  .table-right-menu-item:hover {
+    background-color: #ebf5ff;
+    color: #6bacf2;
+  }
+}
+</style>

+ 64 - 0
src/renderer/src/components/at-menu/index.ts

@@ -0,0 +1,64 @@
+import { createApp } from 'vue'
+import User from '@renderer/mode/User'
+import RightClickMenu from '@renderer/mode/RightClickMenu'
+import AtMenu from '@renderer/components/at-menu/AtMenu.vue'
+import ElementPlus from 'element-plus'
+/**
+ * 函数方式调用
+ * @param isMaster 是否是管理员
+ * @param users 群用户
+ * @param x x坐标
+ * @param y y坐标
+ * @param atCallback 回调函数
+ */
+const atMenu = (
+  isMaster: boolean,
+  users: User[],
+  x: number,
+  y: number,
+  atCallback: (item: string) => void
+): void => {
+  const rightClickInfo = {
+    position: {
+      x: x,
+      y: y
+    },
+    menuList: new Array<RightClickMenu>()
+  }
+  if (isMaster) {
+    rightClickInfo.menuList.push({
+      btnName: '所有人',
+      fn: () => {
+        atCallback('所有人')
+      }
+    })
+  }
+  users.forEach((user) => {
+    rightClickInfo.menuList.push({
+      btnName: user.name,
+      fn: () => {
+        atCallback(user.name)
+      }
+    })
+  })
+
+  const instance = createApp(AtMenu, {
+    rightClickInfo,
+    classIndex: 0,
+    closeRightMenu
+  })
+  // 使用element-plus 并且设置全局的大小
+  instance.use(ElementPlus)
+  const node = document.createElement('div')
+  document.body.appendChild(node)
+  instance.mount(node)
+
+  function closeRightMenu() {
+    instance.unmount()
+    if (document.body.contains(node)) {
+      document.body.removeChild(node)
+    }
+  }
+}
+
+export default atMenu

+ 79 - 0
src/renderer/src/components/forward/MessageForward.vue

@@ -0,0 +1,79 @@
+<template>
+  <el-dialog
+    v-model="show"
+    width="500px"
+    :show-close="false"
+    :destroy-on-close="true"
+    title="消息转发"
+  >
+    <div style="max-height: 300px; overflow-y: scroll; padding: 0 5px">
+      <chats-radio @set-data="setData"></chats-radio>
+    </div>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="props.closeDialog()">取消</el-button>
+        <el-button type="primary" @click="retransmission">转发</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { PropType, ref } from 'vue'
+import Chat from '@renderer/mode/Chat'
+import Message from '@renderer/mode/Message'
+import ChatsRadio from '@renderer/components/ChatsRadio.vue'
+import { useUserStore } from '@renderer/store/userStore'
+import { useWsStore } from '@renderer/store/WsStore'
+import { ElMessage } from 'element-plus/es'
+import { useMessageStore } from '../../store/messageStore'
+
+const props = defineProps({
+  messageList: {
+    type: Object as PropType<Array<Message>>,
+    required: true
+  },
+  closeDialog: {
+    type: Function as PropType<() => void>,
+    required: true
+  }
+})
+const userStore = useUserStore()
+const wsStore = useWsStore()
+//转发的聊天对象
+const toChats = ref([] as Chat[])
+const show = ref(true)
+/**
+ * 设置转发的聊天对象
+ * @param data 聊天对象
+ */
+const setData = (data: Chat[]) => {
+  toChats.value = data
+}
+
+/**
+ * 转发
+ */
+const retransmission = () => {
+  props.messageList.forEach((item) => {
+    const message: Message = JSON.parse(JSON.stringify(item))
+    toChats.value.forEach((item) => {
+      if (null !== userStore.user) {
+        message.id = ''
+        message.chatId = item.id
+        message.fromId = userStore.user.id
+        message.type = item.type
+        message.mine = true
+        wsStore.sendMessage(message)
+      }
+    })
+  })
+  props.closeDialog()
+  ElMessage.success('转发成功')
+  //重置多选转发
+  useMessageStore().setCheckWidth('0')
+  useMessageStore().setCheckList([])
+}
+</script>
+
+<style scoped></style>

+ 24 - 0
src/renderer/src/components/forward/index.ts

@@ -0,0 +1,24 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import MessageForward from './MessageForward.vue'
+import Message from '../../mode/Message'
+
+/**
+ * 转发
+ * @param messageList message
+ */
+const showForward = (messageList: Array<Message>): void => {
+  const instance = createApp(MessageForward, { messageList, closeDialog })
+  // 使用element-plus 并且设置全局的大小
+  instance.use(ElementPlus)
+  const node = document.createElement('div')
+  document.body.appendChild(node)
+  instance.mount(node)
+
+  function closeDialog() {
+    instance.unmount()
+    document.body.removeChild(node)
+  }
+}
+
+export default showForward

+ 194 - 0
src/renderer/src/components/group/add-user/AddGroupUser.vue

@@ -0,0 +1,194 @@
+<template>
+  <el-drawer v-model="drawer" title="添加群成员" size="50%" direction="rtl" @close="close">
+    <el-row class="d-row" :gutter="20">
+      <el-col :span="12">
+        <div style="margin-bottom: 10px">
+          <el-input v-model="keyword" placeholder="搜索"></el-input>
+        </div>
+        <el-scrollbar class="list">
+          <div v-for="item in keywordFilter(friends, keyword)" :key="item.user.id" class="user">
+            <div class="avatar">
+              <vim-avatar :img="item.user.avatar" :name="item.user.name" />
+            </div>
+            <div class="name">{{ item.user.name }}</div>
+            <div class="state">
+              <el-checkbox v-model="item.isCheck" size="large"></el-checkbox>
+            </div>
+          </div>
+        </el-scrollbar>
+      </el-col>
+      <el-col :span="12">
+        <el-scrollbar class="list">
+          <div v-for="item in checkedFriends" :key="item.user.id" class="user">
+            <div class="avatar">
+              <vim-avatar :img="item.user.avatar" :name="item.user.name" />
+            </div>
+            <div class="name">{{ item.user.name }}</div>
+            <div class="state"></div>
+          </div>
+        </el-scrollbar>
+      </el-col>
+    </el-row>
+    <div class="footer text-right">
+      <el-button type="primary" :disabled="canSave" @click.stop="addToGroup">确定</el-button>
+    </div>
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import VimAvatar from '../../VimAvatar.vue'
+import FriendApi from '../../../api/FriendApi'
+import User from '../../../mode/User'
+import { useUserStore } from '../../../store/userStore'
+import { computed, PropType, ref } from 'vue'
+import GroupApi from '../../../api/GroupApi'
+import ChatType from '../../../utils/ChatType'
+import Group from '../../../mode/Group'
+import MessageType from '../../../utils/MessageType'
+import { ElMessage } from 'element-plus/es'
+import { useWsStore } from '../../../store/WsStore'
+import SendCode from '../../../utils/SendCode'
+import { match } from 'pinyin-pro'
+import { storeToRefs } from 'pinia'
+import { loadGroupData } from '../../../hooks/useGroupData'
+import { loadUserOrGroup } from '../../../hooks/useChatInit'
+
+interface UserCheck {
+  user: User
+  isCheck: boolean
+}
+
+const props = defineProps({
+  group: {
+    type: Object as PropType<Group>,
+    required: true,
+    default: null
+  },
+  closeDialog: {
+    type: Function,
+    default: null
+  }
+})
+const keyword = ref('')
+const userStore = useUserStore()
+const friends = ref(new Array<UserCheck>())
+const { user } = storeToRefs(userStore)
+const drawer = ref(true)
+/**
+ * 聊天过滤器
+ * @param items 聊天列表
+ * @param keyword 关键词
+ */
+const keywordFilter = (items: UserCheck[], keyword: string): UserCheck[] => {
+  return items.filter((item) => {
+    return !!(keyword.trim() === '' || match(item.user.name, keyword))
+  })
+}
+
+const tempList = new Array<UserCheck>()
+//过滤掉已经存在的用户
+if (typeof user.value.id !== 'undefined') {
+  FriendApi.friends()
+    .then((res) => {
+      res.data.forEach((item: User) => {
+        tempList.push({ user: item, isCheck: false })
+      })
+      return GroupApi.users(props.group.id)
+    })
+    .then((res) => {
+      const ids = res.data.map((item: User) => item.id)
+      friends.value = tempList.filter((item) => ids.indexOf(item.user.id) === -1)
+    })
+}
+
+const checkedFriends = computed((): Array<UserCheck> => {
+  return friends.value.filter((item) => item.isCheck)
+})
+const userIds = computed((): string[] => {
+  return checkedFriends.value.map((item) => item.user.id)
+})
+const canSave = computed((): boolean => {
+  return userIds.value.length === 0
+})
+
+//执行添加到数据库
+const addToGroup = () => {
+  GroupApi.addUsers(props.group.id, userIds.value).then((res) => {
+    if (res.data.length <= 0) {
+      ElMessage.warning('请等待审核')
+      if (user) {
+        notifyMaster(props.group.master, user.value.id)
+      }
+    }
+    loadGroupData(props.group.id)
+    loadUserOrGroup(props.group.id, ChatType.GROUP, user.value)
+    props.closeDialog()
+  })
+}
+/**
+ * 通知群主
+ * @param masterId 群主id
+ * @param fromId 发送人
+ */
+const notifyMaster = (masterId: string, fromId: string) => {
+  if (masterId) {
+    const sendInfo = {
+      code: SendCode.GROUP_VALIDATE,
+      message: {
+        mine: true,
+        fromId: fromId,
+        chatId: masterId,
+        type: ChatType.FRIEND,
+        messageType: MessageType.text,
+        content: '',
+        timestamp: new Date().getTime()
+      }
+    }
+    useWsStore().send(JSON.stringify(sendInfo))
+  }
+}
+
+const close = () => {
+  props.closeDialog()
+}
+</script>
+
+<style scoped lang="less">
+.d-row {
+  height: 90%;
+}
+
+.user {
+  display: flex;
+  background-color: #eeeeee;
+  padding: 5px 10px;
+  margin-bottom: 5px;
+
+  .avatar {
+    width: 6rem;
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
+  }
+
+  .name {
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
+  }
+
+  .state {
+    width: 6rem;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+}
+
+.footer {
+  position: fixed;
+  right: 15px;
+  bottom: 15px;
+}
+</style>

+ 24 - 0
src/renderer/src/components/group/add-user/index.ts

@@ -0,0 +1,24 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import AddGroupUser from './AddGroupUser.vue'
+import Group from '../../../mode/Group'
+
+/**
+ * 函数方式调用日志
+ * @param group group
+ */
+const showAddGroupUser = (group: Group): void => {
+  const instance = createApp(AddGroupUser, { group, closeDialog })
+  // 使用element-plus 并且设置全局的大小
+  instance.use(ElementPlus)
+  const node = document.createElement('div')
+  document.body.appendChild(node)
+  instance.mount(node)
+
+  function closeDialog() {
+    instance.unmount()
+    document.body.removeChild(node)
+  }
+}
+
+export default showAddGroupUser

+ 122 - 0
src/renderer/src/components/group/announcement/AnnouncementModal.vue

@@ -0,0 +1,122 @@
+<template>
+  <el-dialog v-model="open" width="40rem" center :show-close="false" :close-on-click-modal="false">
+    <div v-if="group" class="info">
+      <el-form ref="ruleFormRef" :inline="false" :model="groupForm" :rules="rules">
+        <el-form-item prop="announcement">
+          <el-input
+            v-model="groupForm.announcement"
+            type="textarea"
+            placeholder="群公告"
+          ></el-input>
+        </el-form-item>
+      </el-form>
+    </div>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button type="primary" @click="saveGroup">保存</el-button>
+        <el-button @click="close">关闭</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref } from 'vue'
+import GroupApi from '../../../api/GroupApi'
+import Group from '../../../mode/Group'
+import { ElForm, ElMessage } from 'element-plus'
+import { loadGroupData } from '@renderer/hooks/useGroupData'
+import SendCode from '../../../utils/SendCode'
+import ChatType from '../../../utils/ChatType'
+import MessageType from '../../../utils/MessageType'
+import { useWsStore } from '../../../store/WsStore'
+
+const ruleFormRef = ref<InstanceType<typeof ElForm>>()
+const groupForm = reactive({
+  id: '',
+  announcement: ''
+})
+
+const rules = reactive({
+  announcement: [
+    {
+      min: 0,
+      max: 100,
+      message: '长度介于0-100',
+      trigger: 'blur'
+    }
+  ]
+})
+defineEmits(['close'])
+const open = ref(false)
+interface IProps {
+  group: Group
+  closeDialog: () => void
+}
+const props = defineProps<IProps>()
+
+if (props.group) {
+  open.value = true
+}
+
+const close = () => {
+  props.closeDialog()
+}
+
+const saveGroup = () => {
+  ruleFormRef.value?.validate((f) => {
+    if (f && props.group) {
+      GroupApi.update(
+        props.group.id,
+        props.group.name,
+        props.group.avatar,
+        props.group.openInvite,
+        props.group.inviteCheck,
+        props.group.prohibition,
+        props.group.prohibitFriend,
+        groupForm.announcement
+      )
+        .then(() => {
+          loadGroupData(props.group.id)
+          const sendInfo = {
+            code: SendCode.MESSAGE,
+            message: {
+              mine: true,
+              fromId: props.group.master,
+              chatId: props.group.id,
+              type: ChatType.GROUP,
+              messageType: MessageType.text,
+              content: `@[所有人] 群公告:${groupForm.announcement}`,
+              timestamp: new Date().getTime(),
+              extend: {
+                atAll: true
+              }
+            }
+          }
+          useWsStore().send(JSON.stringify(sendInfo))
+          ElMessage.success('保存成功')
+          close()
+        })
+        .catch(() => {
+          ElMessage.error('保存失败')
+        })
+    }
+  })
+}
+</script>
+
+<style scoped lang="less">
+.info {
+  text-align: center;
+  line-height: 200%;
+}
+
+.description {
+  padding: 20px 20px 0px 20px;
+  background-color: #ffffff;
+}
+.gongago {
+  margin-right: 10px;
+  font-weight: bold;
+}
+</style>

+ 26 - 0
src/renderer/src/components/group/announcement/index.ts

@@ -0,0 +1,26 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import AnnouncementModal from './AnnouncementModal.vue'
+import Group from '../../../mode/Group'
+import router from '../../../router'
+
+/**
+ * 展示修改公告
+ * @param group group
+ */
+const handleEditAnnouncement = (group: Group): void => {
+  const instance = createApp(AnnouncementModal, { group, closeDialog })
+  // 使用element-plus 并且设置全局的大小
+  instance.use(ElementPlus)
+  instance.use(router)
+  const node = document.createElement('div')
+  document.body.appendChild(node)
+  instance.mount(node)
+
+  function closeDialog() {
+    instance.unmount()
+    document.body.removeChild(node)
+  }
+}
+
+export default handleEditAnnouncement

+ 113 - 0
src/renderer/src/components/group/info/GroupModal.vue

@@ -0,0 +1,113 @@
+<template>
+  <el-dialog v-model="open" width="40rem" center :show-close="false" :close-on-click-modal="false">
+    <div v-if="group" class="info">
+      <vim-avatar :img="group.avatar" :name="group.name" />
+      <div>
+        <div>{{ group.name }}</div>
+      </div>
+    </div>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="close">关闭</el-button>
+        <el-button @click="showGroup">详情</el-button>
+        <el-button v-if="showSend" type="primary" @click="send()">聊天</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { useChatStore } from '../../../store/chatStore'
+import { useRouter } from 'vue-router'
+import VimAvatar from '../../VimAvatar.vue'
+import GroupApi from '../../../api/GroupApi'
+import ChatType from '../../../utils/ChatType'
+import Group from '../../../mode/Group'
+import { useGroupStore } from '../../../store/groupStore'
+import { storeToRefs } from 'pinia'
+
+const groupStore = useGroupStore()
+const { groupList } = storeToRefs(groupStore)
+
+const router = useRouter()
+const store = useChatStore()
+defineEmits(['close'])
+const open = ref(false)
+const props = defineProps({
+  groupId: {
+    type: String,
+    required: true,
+    default: null
+  },
+  showSend: {
+    type: Boolean,
+    required: false,
+    default: false
+  },
+  closeDialog: {
+    type: Function,
+    default: null
+  }
+})
+
+const group = ref<Group>()
+function getGroup(groupId: string) {
+  GroupApi.get(groupId).then((res) => {
+    group.value = res.data
+  })
+}
+
+onMounted(() => {
+  if (props.groupId) {
+    open.value = true
+    getGroup(props.groupId)
+  }
+})
+
+const send = () => {
+  if (group.value) {
+    props.closeDialog()
+    store.openChat({
+      id: group.value.id,
+      name: group.value.name,
+      avatar: group.value.avatar,
+      type: ChatType.GROUP,
+      unreadCount: 0,
+      isLoading: false,
+      loaded: true
+    })
+    router.push('/index/chat')
+  }
+}
+
+const close = () => {
+  props.closeDialog()
+}
+
+const showGroup = () => {
+  props.closeDialog()
+  groupList.value.forEach((item, index) => {
+    if (item.id === props.groupId) {
+      groupStore.setCheckIndex(index)
+    }
+  })
+  router.push(`/index/group/${props.groupId}`)
+}
+</script>
+
+<style scoped lang="less">
+.info {
+  text-align: center;
+  line-height: 200%;
+}
+
+.description {
+  padding: 20px 20px 0px 20px;
+  background-color: #ffffff;
+}
+.gongago {
+  margin-right: 10px;
+  font-weight: bold;
+}
+</style>

+ 26 - 0
src/renderer/src/components/group/info/index.ts

@@ -0,0 +1,26 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import GroupModal from './GroupModal.vue'
+import router from '../../../router'
+
+/**
+ * 函数方式调用日志
+ * @param groupId groupId
+ * @param showSend showSend
+ */
+const showGroup = (groupId: string, showSend: boolean): void => {
+  const instance = createApp(GroupModal, { groupId, showSend, closeDialog })
+  instance.use(router)
+  // 使用element-plus 并且设置全局的大小
+  instance.use(ElementPlus)
+  const node = document.createElement('div')
+  document.body.appendChild(node)
+  instance.mount(node)
+
+  function closeDialog() {
+    instance.unmount()
+    document.body.removeChild(node)
+  }
+}
+
+export default showGroup

+ 46 - 0
src/renderer/src/components/messages/MessageEvent.vue

@@ -0,0 +1,46 @@
+<template>
+  <div class="im-chat-event" :class="message.mine ? 'mine' : ''">
+    <el-tag type="info">
+      {{
+        formatDistanceToNow(message.timestamp, {
+          locale: zhCN,
+          addSuffix: true
+        })
+      }}:{{ message.content }}
+    </el-tag>
+  </div>
+</template>
+
+<script setup lang="ts">
+import Message from '@renderer/mode/Message'
+import { formatDistanceToNow } from 'date-fns'
+import { zhCN } from 'date-fns/locale'
+
+interface Props<T> {
+  message: T
+}
+
+defineProps<Props<Message>>()
+</script>
+
+<style>
+.im-chat-event {
+  margin-top: 10px;
+  margin-left: -60px;
+  width: calc(100% + 60px);
+  font-size: 12px;
+  text-align: center;
+  color: #999;
+}
+
+.im-chat-event.mine {
+  margin-left: 0;
+  width: calc(100% + 60px);
+}
+
+.im-chat-event > div > div {
+  background-color: #f8f8f8;
+  font-size: 12px;
+  color: #999999;
+}
+</style>

+ 73 - 0
src/renderer/src/components/messages/MessageFile.vue

@@ -0,0 +1,73 @@
+<template>
+  <div class="im-chat-text" style="width: 50%" @contextmenu="messageRightEvent(message, $event)">
+    <a
+      class="file-box"
+      :title="getFileName(message.extend?.fileName)"
+      :href="message.extend?.url"
+      @click="ChatUtils.openProxy($event, proxy)"
+    >
+      <div class="file-icon">
+        <i class="iconfont icon-v-xiazai"></i>
+      </div>
+      <div class="file-text">
+        <div class="file-name">{{ getFileName(message.extend?.fileName) }}</div>
+      </div>
+    </a>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { getCurrentInstance } from 'vue'
+import Message from '@renderer/mode/Message'
+import messageRightEvent from '@renderer/hooks/useMessageRightEvent'
+import ChatUtils from '@renderer/utils/ChatUtils'
+import getFileName from '@renderer/utils/FileUtils'
+
+//eslint-disable-next-line @typescript-eslint/ban-ts-comment
+//@ts-ignore
+const { proxy } = getCurrentInstance()
+interface Props<T> {
+  message: T
+}
+defineProps<Props<Message>>()
+</script>
+
+<style lang="less" scoped>
+.file-box {
+  width: 100%;
+  display: flex;
+  background-color: #efefef;
+  color: #666666;
+
+  .file-icon {
+    background-color: #cccccc;
+    padding: 10px;
+    width: 60px;
+    flex-shrink: 0;
+
+    .iconfont {
+      line-height: normal;
+      font-size: 4rem;
+    }
+  }
+
+  .file-text {
+    width: 0;
+    padding: 10px;
+    flex: 5;
+    display: flex;
+    align-items: center;
+    flex-shrink: 0;
+    overflow: hidden;
+
+    .file-name {
+      -webkit-line-clamp: 2;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      overflow-wrap: break-word;
+      word-break: break-all;
+    }
+  }
+}
+</style>

+ 26 - 0
src/renderer/src/components/messages/MessageImage.vue

@@ -0,0 +1,26 @@
+<template>
+  <div
+    class="im-chat-text"
+    style="max-width: 300px; line-height: 100%; font-size: inherit"
+    @contextmenu="messageRightEvent(message, $event)"
+  >
+    <img
+      alt="图片"
+      v-preview
+      :src="message.extend?.url"
+      style="width: 100%"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import Message from '@renderer/mode/Message'
+import messageRightEvent from '@renderer/hooks/useMessageRightEvent'
+
+interface Props<T> {
+  message: T
+}
+defineProps<Props<Message>>()
+</script>
+
+<style scoped></style>

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно