sys5923812@126.com 1 month ago
commit
b8ccc16ae6
100 changed files with 51803 additions and 0 deletions
  1. 21 0
      .gitignore
  2. 36 0
      README.en.md
  3. 20 0
      index.html
  4. 16664 0
      package-lock.json
  5. 82 0
      package.json
  6. 8645 0
      pnpm-lock.yaml
  7. 15 0
      readme.md
  8. 93 0
      src/App.vue
  9. 70 0
      src/api/Auth.ts
  10. 87 0
      src/api/ChatApi.ts
  11. 46 0
      src/api/CollectApi.ts
  12. 45 0
      src/api/DeptApi.ts
  13. 140 0
      src/api/FetchRequest.ts
  14. 72 0
      src/api/FriendApi.ts
  15. 153 0
      src/api/GroupApi.ts
  16. 37 0
      src/api/GroupInviteApi.ts
  17. 35 0
      src/api/ImmunityApi.ts
  18. 42 0
      src/api/Login.ts
  19. 75 0
      src/api/MessageApi.ts
  20. 24 0
      src/api/SettingApi.ts
  21. 29 0
      src/api/UploadApi.ts
  22. 49 0
      src/api/UserApi.ts
  23. 254 0
      src/api/WsRequest.ts
  24. 184 0
      src/colorui/animation.css
  25. 102 0
      src/colorui/components/cu-custom.vue
  26. 1226 0
      src/colorui/icon.css
  27. 3926 0
      src/colorui/main.css
  28. 79 0
      src/components/ChatSetting.vue
  29. 47 0
      src/components/Faces.vue
  30. 77 0
      src/components/HandleQuoteMessage.vue
  31. 161 0
      src/components/IndexBar.vue
  32. 17 0
      src/components/LoadMapUser.vue
  33. 184 0
      src/components/MessageView.vue
  34. 111 0
      src/components/MultipleForward.vue
  35. 36 0
      src/components/NoData.vue
  36. 78 0
      src/components/QuoteMessage.vue
  37. 50 0
      src/components/Time.vue
  38. 39 0
      src/components/UserAvatar.vue
  39. 27 0
      src/components/UserAvatarTag.vue
  40. 27 0
      src/components/UserNameTag.vue
  41. 79 0
      src/components/VimAvatar.vue
  42. 268 0
      src/components/VimUpload.vue
  43. 149 0
      src/components/Voice.vue
  44. 214 0
      src/components/ls-dom-video/ls-dom-video.vue
  45. 14 0
      src/components/messages/MessageEvent.vue
  46. 61 0
      src/components/messages/MessageFile.vue
  47. 30 0
      src/components/messages/MessageImage.vue
  48. 84 0
      src/components/messages/MessageMultipleForward.vue
  49. 19 0
      src/components/messages/MessageText.vue
  50. 24 0
      src/components/messages/MessageVideo.vue
  51. 84 0
      src/components/messages/MessageVoice.vue
  52. 153 0
      src/components/mix-tree/mix-tree.vue
  53. 372 0
      src/components/uni-fab/uni-fab.vue
  54. 31 0
      src/config/VimConfig.ts
  55. 8 0
      src/env.d.ts
  56. 91 0
      src/hooks/useChatInit.ts
  57. 29 0
      src/hooks/useMessageComponent.ts
  58. 70 0
      src/hooks/useMessageImageLoad.ts
  59. 26 0
      src/hooks/useMoveMenu.ts
  60. 176 0
      src/hybrid/html/answer.html
  61. 184 0
      src/hybrid/html/calling.html
  62. 105 0
      src/hybrid/html/css/video-call.css
  63. BIN
      src/hybrid/html/images/calling.mp3
  64. BIN
      src/hybrid/html/images/mt/audioffx.png
  65. BIN
      src/hybrid/html/images/mt/audiooff.png
  66. BIN
      src/hybrid/html/images/mt/audioon.png
  67. BIN
      src/hybrid/html/images/mt/camerachange.png
  68. BIN
      src/hybrid/html/images/mt/cameraoff.png
  69. BIN
      src/hybrid/html/images/mt/cameraoffx.png
  70. BIN
      src/hybrid/html/images/mt/cameraon.png
  71. BIN
      src/hybrid/html/images/mt/exitmt.png
  72. BIN
      src/hybrid/html/images/mt/icon_off.png
  73. BIN
      src/hybrid/html/images/mt/icon_on.png
  74. BIN
      src/hybrid/html/images/mt/icon_spk0.png
  75. BIN
      src/hybrid/html/images/mt/icon_spk1.png
  76. BIN
      src/hybrid/html/images/mt/loading.gif
  77. BIN
      src/hybrid/html/images/mt/pt.png
  78. 8 0
      src/hybrid/html/js/peerjs.min.js
  79. 1 0
      src/hybrid/html/js/uni.webview.1.5.4.js
  80. 83 0
      src/hybrid/html/js/video-call.js
  81. 15929 0
      src/hybrid/html/js/vue.global.js
  82. 39 0
      src/main.ts
  83. 194 0
      src/manifest.json
  84. 6 0
      src/mode/AjaxResult.ts
  85. 18 0
      src/mode/Chat.ts
  86. 7 0
      src/mode/ChatSimple.ts
  87. 12 0
      src/mode/Collect.ts
  88. 9 0
      src/mode/Dept.ts
  89. 7 0
      src/mode/Details.ts
  90. 12 0
      src/mode/Extend.ts
  91. 10 0
      src/mode/Friend.ts
  92. 12 0
      src/mode/Group.ts
  93. 12 0
      src/mode/GroupInvite.ts
  94. 6 0
      src/mode/Immunity.ts
  95. 23 0
      src/mode/Message.ts
  96. 10 0
      src/mode/Receipt.ts
  97. 12 0
      src/mode/Setting.ts
  98. 11 0
      src/mode/User.ts
  99. 6 0
      src/mode/UserSimple.ts
  100. 0 0
      src/pages.json

+ 21 - 0
.gitignore

@@ -0,0 +1,21 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+*.local
+
+# Editor directories and files
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 36 - 0
README.en.md

@@ -0,0 +1,36 @@
+# v-im-h5
+
+#### Description
+{**When you're done, you can delete the content in this README and update the file with details for others getting started with your repository**}
+
+#### Software Architecture
+Software architecture description
+
+#### Installation
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### Instructions
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### Contribution
+
+1.  Fork the repository
+2.  Create Feat_xxx branch
+3.  Commit your code
+4.  Create Pull Request
+
+
+#### Gitee Feature
+
+1.  You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
+2.  Gitee blog [blog.gitee.com](https://blog.gitee.com)
+3.  Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
+4.  The most valuable open source project [GVP](https://gitee.com/gvp)
+5.  The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
+6.  The most popular members  [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

+ 20 - 0
index.html

@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="UTF-8" />
+    <script>
+      var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
+        CSS.supports('top: constant(a)'))
+      document.write(
+        '<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
+        (coverSupport ? ', viewport-fit=cover' : '') + '" />')
+    </script>
+    <title></title>
+    <!--preload-links-->
+    <!--app-context-->
+  </head>
+  <body>
+    <div id="app"><!--app-html--></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

File diff suppressed because it is too large
+ 16664 - 0
package-lock.json


+ 82 - 0
package.json

@@ -0,0 +1,82 @@
+{
+  "name": "V-IM-APP",
+  "version": "2.7.0",
+  "scripts": {
+    "dev:app": "uni -p app",
+    "dev:app-android": "uni -p app-android",
+    "dev:app-ios": "uni -p app-ios",
+    "dev:custom": "uni -p",
+    "dev:h5": "uni",
+    "dev:h5:ssr": "uni --ssr",
+    "dev:mp-alipay": "uni -p mp-alipay",
+    "dev:mp-baidu": "uni -p mp-baidu",
+    "dev:mp-jd": "uni -p mp-jd",
+    "dev:mp-kuaishou": "uni -p mp-kuaishou",
+    "dev:mp-lark": "uni -p mp-lark",
+    "dev:mp-qq": "uni -p mp-qq",
+    "dev:mp-toutiao": "uni -p mp-toutiao",
+    "dev:mp-weixin": "uni -p mp-weixin",
+    "dev:quickapp-webview": "uni -p quickapp-webview",
+    "dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
+    "dev:quickapp-webview-union": "uni -p quickapp-webview-union",
+    "build:app": "uni build -p app",
+    "build:app-android": "uni build -p app-android",
+    "build:app-ios": "uni build -p app-ios",
+    "build:custom": "uni build -p",
+    "build:h5": "uni build",
+    "build:h5:ssr": "uni build --ssr",
+    "build:mp-alipay": "uni build -p mp-alipay",
+    "build:mp-baidu": "uni build -p mp-baidu",
+    "build:mp-jd": "uni build -p mp-jd",
+    "build:mp-kuaishou": "uni build -p mp-kuaishou",
+    "build:mp-lark": "uni build -p mp-lark",
+    "build:mp-qq": "uni build -p mp-qq",
+    "build:mp-toutiao": "uni build -p mp-toutiao",
+    "build:mp-weixin": "uni build -p mp-weixin",
+    "build:quickapp-webview": "uni build -p quickapp-webview",
+    "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
+    "build:quickapp-webview-union": "uni build -p quickapp-webview-union",
+    "type-check": "vue-tsc --noEmit"
+  },
+  "dependencies": {
+    "@dcloudio/uni-app": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-app-plus": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-components": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-h5": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-alipay": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-baidu": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-jd": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-kuaishou": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-lark": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-qq": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-toutiao": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-mp-xhs": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-quickapp-webview": "3.0.0-alpha-4000420240315001",
+    "date-fns": "^2.30.0",
+    "image-conversion": "^2.1.1",
+    "jsencrypt": "^3.2.1",
+    "pinia": "2.0.36",
+    "pinia-plugin-persist-uni": "^1.3.1",
+    "pinyin-pro": "^3.15.1",
+    "vue": "^3.3.11",
+    "vue-clipboard3": "^2.0.0",
+    "vue-i18n": "^9.1.9"
+  },
+  "devDependencies": {
+    "@dcloudio/types": "^3.3.2",
+    "@dcloudio/uni-automator": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-cli-shared": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-stacktracey": "3.0.0-alpha-4000420240315001",
+    "@dcloudio/uni-uts-v1": "3.0.0-alpha-3080720230627002",
+    "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4000420240315001",
+    "@vue/runtime-core": "^3.3.11",
+    "@vue/tsconfig": "^0.1.3",
+    "esbuild": "0.17.19",
+    "sass": "^1.47.0",
+    "sass-loader": "^7.3.1",
+    "typescript": "^4.9.4",
+    "vite": "4.3.5",
+    "vue-tsc": "^1.0.24"
+  }
+}

File diff suppressed because it is too large
+ 8645 - 0
pnpm-lock.yaml


+ 15 - 0
readme.md

@@ -0,0 +1,15 @@
+# 版权说明:此软件仅供已购买的用户个人或公司使用,未经授权不得公开源码、源码转发他人、转卖源码、二次开发后售卖、非授权使用,违者需赔付作者人民币100万元。
+### 作者:乐天
+
+### 使用说明请参照开源版本。
+
+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 (先修改:v-im-pc/src/renderer/src/main.ts 第六行 import windowControl from './hooks/webControl' )
+7. 打包exe npm run build:win (先修改:v-im-pc/src/renderer/src/main.ts 第六行 import windowControl from './hooks/windowControl')
+8. 打包mac和linux 和 打包exe 一样配置,就是命令不一样,参考package.json
+9. 账号 admin/kunchong  ry/kunchong
+10. 后台管理界面直接使用ruoyi-vue3即可,请自行去git下载:https://github.com/yangzongzhuan/RuoYi-Vue3 vue2版本:https://gitee.com/y_project/RuoYi-Vue

+ 93 - 0
src/App.vue

@@ -0,0 +1,93 @@
+<script setup lang="ts">
+import {onHide, onLaunch, onShow} from "@dcloudio/uni-app";
+import {getCurrentInstance} from "vue";
+import {useSysStore} from "@/store/sysStore";
+import Auth from "@/api/Auth";
+import {useWsStore} from "@/store/WsStore";
+import app_upgrade from '@/uni_modules/app-upgrade/js_sdk/index.js'
+import packageJson from '../package.json'
+import update from '../update.json'
+
+const wsStore = useWsStore();
+onLaunch(() => {
+  uni.getSystemInfo({
+    success: function (e) {
+      //@ts-ignore
+      const {proxy} = getCurrentInstance();
+      // #ifndef MP
+      proxy.statusBar = e.statusBarHeight;
+      if (e.platform == 'android') {
+        //@ts-ignore
+        proxy.customBar = e.statusBarHeight + 50;
+      } else {
+        //@ts-ignore
+        proxy.customBar = e.statusBarHeight + 45;
+      }
+      ;
+      // #endif
+
+      // #ifdef MP-WEIXIN
+      proxy.statusBar = e.statusBarHeight;
+      //@ts-ignore
+      let custom = wx.getMenuButtonBoundingClientRect();
+      proxy.Custom = custom;
+      //@ts-ignore
+      proxy.customBar = custom.bottom + custom.top - e.statusBarHeight;
+      // #endif
+
+      // #ifdef MP-ALIPAY
+      proxy.statusBar = e.statusBarHeight;
+      //@ts-ignore
+      proxy.customBar = e.statusBarHeight + e.titleBarHeight;
+      // #endif
+      // #ifdef APP-PLUS
+      //自动更新 安卓
+      if (e.platform === 'android') {
+        app_upgrade(async () => {
+          //查询是否更新
+          const {statusCode, data} = await uni.request({
+            url: update.checkUrl,
+            method: 'GET'
+          })
+          const version = packageJson.version
+          if (statusCode === 200) {
+            return {
+              changelog: data.changelog,
+              status: data.android > version ? 1 : 0, // 0 无新版本 | 1 有新版本
+              path: data.url // 新apk地址
+            }
+          }
+        })
+      }
+      // #endif
+    }
+  })
+});
+onShow(() => {
+  if (Auth.getToken()) {
+    if (wsStore.wsRequest.socket?.readyState === 1) {
+      wsStore.checkStatus()
+    } else {
+      wsStore.close(true)
+      wsStore.init()
+    }
+  }
+  useSysStore().setShow(true)
+});
+onHide(() => {
+  useSysStore().setShow(false)
+});
+
+</script>
+<style>
+@import "colorui/main.css";
+@import "colorui/icon.css";
+@import "static/css/font.css";
+
+page {
+  height: 100%;
+}
+.tab-bar-mar {
+  margin-bottom: 100upx
+}
+</style>

+ 70 - 0
src/api/Auth.ts

@@ -0,0 +1,70 @@
+import FetchRequest from "@/api/FetchRequest";
+import { logout } from '@/api/Login'
+import { useWsStore } from '@/store/WsStore'
+import {useChatStore} from "@/store/chatStore";
+import MessageUtils from "@/utils/MessageUtils";
+
+class Auth {
+	static getToken = () : string => {
+		return uni.getStorageSync("access_token") ?? "";
+	};
+
+	static setToken = (token : string) : void => {
+		uni.setStorageSync("access_token", token);
+	};
+
+	static setRefreshToken = (token : string) : void => {
+		uni.setStorageSync("refresh_token", token);
+	};
+
+	static getRefreshToken = () : string => {
+		return uni.getStorageSync("refresh_token") ?? "";
+	};
+
+	static clearToken = () : void => {
+		uni.removeStorageSync("access_token");
+	};
+
+	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,
+			};
+			FetchRequest.fetch(`${FetchRequest.getHost()}/api/sys/users/my`, config)
+				.then(res => {
+					if (res.data.code === 200) {
+						uni.setStorageSync('userId',res.data.id)
+						resolve(true)
+					}else{
+						reject(false)
+					}
+				}).catch(err => {
+					console.error(err)
+					reject(false)
+				})
+		})
+	}
+
+	static logout = () => {
+		logout()
+			.finally(() => {
+				useChatStore().clearMessage();
+				this.clearToken();
+				useWsStore().close(true)
+				uni.redirectTo({
+					url:"/pages/login/login",
+					fail(err) {
+						MessageUtils.error('无法跳转到登录界面');
+					}
+				})
+			})
+	}
+}
+export default Auth;

+ 87 - 0
src/api/ChatApi.ts

@@ -0,0 +1,87 @@
+import FetchRequest from '@/api/FetchRequest'
+import type Chat from '@/mode/Chat'
+import type AjaxResult from '@/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) => {
+      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

+ 46 - 0
src/api/CollectApi.ts

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

+ 45 - 0
src/api/DeptApi.ts

@@ -0,0 +1,45 @@
+import FetchRequest from "@/api/FetchRequest";
+
+class DeptApi {
+  static url = "/api/sys/depts";
+
+  /**
+   * 获取父部门
+   * @param deptId 部门ID
+   */
+  static parent(deptId: string): Promise<any> {
+    return FetchRequest.get(`${this.url}/parent?deptId=${deptId}`, true);
+  }
+
+  /**
+   * 获取所有部门
+   */
+  static list(): Promise<any> {
+    return FetchRequest.get(this.url, true);
+  }
+
+  /**
+   * 获取部门
+   * @param id 部门ID
+   */
+  static get(id: string): Promise<any> {
+    return FetchRequest.get(`${this.url}/${id}`, true);
+  }
+
+  /**
+   * 获取部门用户
+   * @param deptId 部门ID
+   */
+  static users(deptId: string): Promise<any> {
+    return FetchRequest.get(`${this.url}/${deptId}/users`, true);
+  }
+
+  /**
+   * 获取部门人数
+   */
+  static count(): Promise<any> {
+    return FetchRequest.get(`${this.url}/count`, true);
+  }
+}
+
+export default DeptApi;

+ 140 - 0
src/api/FetchRequest.ts

@@ -0,0 +1,140 @@
+import Auth from "@/api/Auth";
+import VimConfig from "@/config/VimConfig";
+import MessageUtils from "@/utils/MessageUtils";
+
+/**
+ * 请求类,支持无感刷新token
+ * @author 乐天
+ */
+class FetchRequest {
+    private static instance: FetchRequest;
+
+    private constructor() {
+    }
+
+    /**
+     * 单例构造方法,构造一个广为人知的接口,供用户对该类进行实例化
+     * @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 this.fetch(this.getHost() + url, config)
+            .then((response: any) => {
+                return this.check(response);
+            });
+    };
+
+    fetch = (url: string, config: RequestInit): Promise<any> => {
+        return new Promise((resolve, reject) => {
+            // @ts-ignore
+            uni.request({
+                url: url,
+                method: config.method,
+                data: config.body,
+                header: config.headers,
+                success(res) {
+                    resolve(res)
+                },
+                fail(err) {
+                    MessageUtils.error('无法链接网络');
+                    reject(err)
+                }
+            })
+        })
+
+    }
+
+    /**
+     * 检查请求返回值,如果token失效,执行刷新方法
+     * @param response 请求响应数据
+     */
+    check = (response: any) => {
+        //token 失效
+        if (response.statusCode === 200) {
+            let res = response.data;
+            if (res.code === 401) {
+                Auth.logout()
+            } else if (res.code !== 200) {
+                MessageUtils.error(res.msg)
+                return Promise.reject(res);
+            } else {
+                return Promise.resolve(res);
+            }
+
+        } else {
+            MessageUtils.error("请求出错,状态码:" + response.statusCode)
+            return Promise.reject("请求出错");
+        }
+    };
+    /**
+     * 获取有效的ip
+     */
+    getEffectiveIp = (): string => {
+        return VimConfig.host;
+    };
+    getHost = (): string => {
+        // return `${VimConfig.httProtocol}://${VimConfig.host}:${VimConfig.httPort}`
+        return `${VimConfig.httProtocol}://${VimConfig.host}`
+    };
+
+    // 有些 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();

+ 72 - 0
src/api/FriendApi.ts

@@ -0,0 +1,72 @@
+import FetchRequest from "@/api/FetchRequest";
+import type Friend from "@/mode/Friend";
+
+class FriendApi {
+    static url = "/api/sys/friends";
+
+    /**
+     * 获取用户的所有好友
+     * @param id 用户ID
+     */
+    static friends(): Promise<any> {
+        return FetchRequest.get(`${this.url}?state=0`, true);
+    }
+
+    /**
+     * 获取用户的待审核好友
+     */
+    static validateList(): Promise<any> {
+        return FetchRequest.get(`${this.url}/validateList`, true);
+    }
+
+    /**
+     * 添加好友
+     * @param friend 好友
+     */
+    static add(friend: Friend): Promise<any> {
+        return FetchRequest.post(`${this.url}/add`, JSON.stringify(friend), true);
+    }
+
+    /**
+     * 审核好友
+     * @param userId 用户ID
+     * @param friendId 好友
+     * @param state 状态
+     */
+    static check(userId: string, friendId: string, state: string): Promise<any> {
+        const data = {
+            friendId: friendId.toString(),
+            state: state.toString(),
+        };
+        return FetchRequest.patch(
+            `${this.url}/${userId}`,
+            JSON.stringify(data),
+            true
+        );
+    }
+
+    /**
+     * 同意加好友
+     * @param friendId 好友ID
+     */
+    static agree(friendId: string): Promise<any> {
+        return FetchRequest.post(`${this.url}/agree`, friendId, true);
+    }
+
+    /**
+     * 删除好友
+     * @param friendId 好友ID
+     */
+    static delete(friendId: string): Promise<any> {
+        return FetchRequest.del(`${this.url}/delete`, friendId, true);
+    }
+
+    /**
+     * 判断是否好友
+     */
+    static isFriend(friendId: string): Promise<any> {
+        return FetchRequest.get(`${this.url}/isFriend?friendId=${friendId}`, true);
+    }
+}
+
+export default FriendApi;

+ 153 - 0
src/api/GroupApi.ts

@@ -0,0 +1,153 @@
+import FetchRequest from "@/api/FetchRequest";
+import type AjaxResult from "@/mode/AjaxResult";
+
+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<any> {
+    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 announcement 群公告
+   * @param prohibition 禁言
+   * @param prohibitFriend 是否允许加好友
+   * @param openInvite  是否开放邀请
+   */
+  static update(
+    id: string,
+    name: string,
+    avatar: string,
+    openInvite: string,
+    inviteCheck: string,
+    prohibition: string,
+    prohibitFriend: string,
+    announcement: string
+  ): Promise<any> {
+    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<any> {
+    return FetchRequest.get(`${this.url}/${id}`, true);
+  }
+
+  /**
+   * 查询当前用户的群组
+   */
+  static list(): Promise<any> {
+    return FetchRequest.get(this.url, true);
+  }
+
+  /**
+   * 获取一个群的所有用户
+   * @param id 群id
+   */
+  static users(id: string): Promise<any> {
+    return FetchRequest.get(`${this.url}/${id}/users`, true);
+  }
+
+  /**
+   * 删除群
+   * @param id 用户ID
+   */
+  static delete(id: string): Promise<any> {
+    return FetchRequest.del(`${this.url}/${id}`, "", true);
+  }
+
+  /**
+   * 退群
+   * @param id 用户ID
+   */
+  static exit(id: string): Promise<any> {
+    return FetchRequest.del(`${this.url}/${id}/exit`, "", true);
+  }
+
+  /**
+   * 添加群成员
+   * @param id 群id
+   * @param userId userId
+   */
+  static addUsers(id: string, userId: string[]): Promise<any> {
+    return FetchRequest.post(
+      `${this.url}/${id}/users`,
+      JSON.stringify(userId),
+      true
+    );
+  }
+
+  /**
+   * 删除群
+   * @param id 用户ID
+   * @param userId 用户ID
+   */
+  static deleteUser(id: string, userId: string): Promise<any> {
+    return FetchRequest.del(`${this.url}/${id}/users/${userId}`, "", true);
+  }
+
+
+  /**
+   * 删除多个群用户
+   * @param id 用户ID
+   * @param userId 用户ID
+   */
+  static deleteUsers(id: string, userId: string[]): Promise<any> {
+    return FetchRequest.del(`${this.url}/${id}/users`, JSON.stringify(userId), true);
+  }
+
+  /**
+   * 转让
+   * @param id 群id
+   * @param userId userId
+   */
+  static transference(id: string, userId: string): Promise<any> {
+    return FetchRequest.post(`${this.url}/${id}/transference/${userId}`, '', true)
+  }
+}
+
+export default GroupApi;

+ 37 - 0
src/api/GroupInviteApi.ts

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

+ 35 - 0
src/api/ImmunityApi.ts

@@ -0,0 +1,35 @@
+import FetchRequest from '@/api/FetchRequest'
+import type Immunity from '@/mode/Immunity'
+// @ts-ignore
+import type AjaxResult from '@/mode/AjaxResult'
+
+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

+ 42 - 0
src/api/Login.ts

@@ -0,0 +1,42 @@
+import FetchRequest from "@/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: loginBody) =>  FetchRequest.post('/register', JSON.stringify(data), false)
+
+
+// 获取用户详细信息
+export const getInfo = () => {
+  return FetchRequest.get("/getInfo", false);
+};
+
+// 退出方法
+export const logout = () => FetchRequest.post("/logout", "", true);
+
+// 获取验证码
+export const getCodeImg = () => FetchRequest.get(`/captchaImage?version=${version}`, false)

+ 75 - 0
src/api/MessageApi.ts

@@ -0,0 +1,75 @@
+import FetchRequest from "@/api/FetchRequest";
+import type AjaxResult from "@/mode/AjaxResult";
+import type Message from "@/mode/Message";
+
+class MessageApi {
+  static url = "/api/sys/messages";
+  static Shortcut_url = "/imShortcut/queryAutoReplyShortcutContent";
+  static Shortcut_hf_url = "/imShortcut/queryById";
+  static list(
+    chatId: string,
+    fromId: string,
+    type: string,
+    pageSize: number
+  ) {
+    const param =
+      `?chatId=${chatId}&fromId=${fromId}&type=${type}&pageSize=${pageSize}`;
+    return FetchRequest.get(this.url + param, true);
+  }
+
+  static getReadTime(chatId: string, fromId: string): Promise<AjaxResult<string>> {
+    return FetchRequest.get(`${this.url}/getReadTime?chatId=${chatId}&fromId=${fromId}`, true)
+  }
+
+  static page(
+      chatId: string,
+      fromId: string,
+      type: string,
+      messageType: string,
+      current: number,
+      size: number
+    ) {
+      const param =
+        `?chatId=${chatId}&fromId=${fromId}&chatType=${type}&messageType=${messageType}&current=${current}&size=${size}`;
+      return FetchRequest.get(`${this.url}/page${param}`, true);
+    }
+
+  static search(
+      chatId: string,
+      fromId: string,
+      searchText: string,
+      type: string,
+      messageType: string,
+      current: number,
+      dateRange1: string,
+      dateRange2: string,
+      size: number
+  ) {
+    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 Shortcut(
+    userId: string,
+  ){
+    const param =
+      "?userId=" +
+      userId
+    return FetchRequest.get(this.Shortcut_url + param, true);
+  }
+  static Shortcuthf(
+    id: string,
+  ) {
+    const param =
+      "?id=" +
+      id
+    return FetchRequest.get(this.Shortcut_hf_url + param, true);
+  }
+}
+
+export default MessageApi;

+ 24 - 0
src/api/SettingApi.ts

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

+ 29 - 0
src/api/UploadApi.ts

@@ -0,0 +1,29 @@
+import MessageType from "../utils/MessageType";
+import FetchRequest from '@/api/FetchRequest';
+import Auth from '@/api/Auth';
+import VimConfig from "@/config/VimConfig";
+
+const upload = (tempFilePath : string) => {
+	return new Promise((resolve, reject) => {
+		uni.uploadFile({
+			url: `${FetchRequest.getHost()}/${VimConfig.uploadType}/upload`, //仅为示例,非真实的接口地址
+			filePath: tempFilePath,
+			name: 'file',
+			header: {
+				"Access-Control-Allow-Origin": "*",
+				"Authorization": `Bearer ${Auth.getToken()}`,
+			},
+			formData: {
+				'type': MessageType.voice
+			},
+			success: (res) => {
+				const data = JSON.parse(res.data)
+				resolve(data)
+			},
+			fail: (err) => {
+				reject(err)
+			}
+		})
+	})
+}
+export default upload

+ 49 - 0
src/api/UserApi.ts

@@ -0,0 +1,49 @@
+import FetchRequest from "@/api/FetchRequest";
+import type User from "@/mode/User";
+
+/**
+ * 用户接口
+ */
+class UserApi {
+  static url = "/api/sys/users";
+
+  static getUser(id: string): Promise<any> {
+    return FetchRequest.get(`${this.url}/${id}`, true);
+  }
+
+  static currentUser(): Promise<any> {
+    return FetchRequest.get(`${this.url}/my`, true);
+  }
+
+  static update(id: string, user: User): Promise<any> {
+    user.id = id;
+    return FetchRequest.put(`${this.url}/update` , JSON.stringify(user), true);
+  }
+
+  /**
+   * search好友
+   * @param mobile mobile
+   */
+  static search(mobile: string): Promise<any> {
+    return FetchRequest.get(`${this.url}/search?mobile=${mobile}`, true);
+  }
+
+  /**
+   * 刷新用户密钥
+   * @param oldPassword
+   * @param newPassword
+   */
+  static updateUserPwd(oldPassword: string, newPassword: string): Promise<any> {
+    const data = {
+      oldPassword: oldPassword,
+      newPassword: newPassword,
+    };
+    return FetchRequest.put(
+        `${this.url}/updatePwd`,
+        JSON.stringify(data),
+        true
+    );
+  }
+}
+
+export default UserApi;

+ 254 - 0
src/api/WsRequest.ts

@@ -0,0 +1,254 @@
+import type Message from '@/mode/Message'
+import type Receipt from '@/mode/Receipt'
+import SendCode from '@/utils/SendCode'
+import ChatUtils from '@/utils/ChatUtils'
+import vimConfig from '@/config/VimConfig'
+import Auth from '@/api/Auth'
+import ChatType from '@/utils/ChatType'
+import MessageType from '@/utils/MessageType'
+import {useUserStore} from '@/store/userStore'
+import {useChatStore} from '@/store/chatStore'
+import {useFriendStore} from '@/store/friendStore'
+import {useGroupStore} from '@/store/groupStore'
+import MessageUtils from '@/utils/MessageUtils'
+import VimPlugin from "@/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: number | null
+  reconnectTimeoutTask: number | null
+  socket: UniApp.SocketTask | 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 = `${new Date().getTime()}`;
+  }
+
+  static getInstance() {
+    if (!this.instance) {
+      this.instance = new WsRequest()
+    }
+    return this.instance
+  }
+
+  public init(): void {
+    this.closeByUser = false
+    // this.url = `${vimConfig.wsProtocol}://${vimConfig.host}:${vimConfig.wsPort}?token=${Auth.getToken()}&client=${vimConfig.client}&uuid=${this.uuid}`
+	this.url = `${vimConfig.wsProtocol}://newim.taohaowan.com/ws?token=${Auth.getToken()}&client=${vimConfig.client}&uuid=${this.uuid}`
+    this.socket = uni.connectSocket({
+      url: this.url, //接口地址。
+      fail: (err) => {
+        console.log(err, '连接错误')
+      }
+    })
+    this.socket.onOpen( () => {
+      //告知服务器准备就绪
+      this.send(ready)
+      // 开启检测
+      this.reset()
+    })
+
+    // 如果希望websocket连接一直保持,在close或者error上绑定重新连接方法。
+    this.socket.onClose(() => {
+      if (!this.closeByUser) {
+        this.reconnect()
+      }
+    })
+
+    this.socket.onError (() => {
+      this.reconnect()
+    })
+
+    this.socket.onMessage((res) => {
+      const data = res.data
+      //防止每次心跳都要进行 JSON.parse 优化性能
+      if (data === ping) {
+        this.reset()
+        return
+      }
+      const sendInfo = JSON.parse(data)
+      // 真正的消息类型
+      if (sendInfo.code === SendCode.MESSAGE) {
+        this.onmessage(sendInfo.message)
+      } else if (sendInfo.code === SendCode.OTHER_LOGIN && this.uuid !== sendInfo.message.uuid) {
+        MessageUtils.error('账号已经在别处登录')
+        Auth.logout()
+      }else if (sendInfo.code === SendCode.NEW_FRIEND) {
+        useFriendStore().loadValidateList()
+        useFriendStore().loadData()
+      } else if (sendInfo.code === SendCode.GROUP_VALIDATE) {
+        useGroupStore().loadWaitCheckList()
+      }
+      else if (sendInfo.code === SendCode.READ){
+        useChatStore().setLastReadTime(sendInfo.message)
+      }
+      else {
+        VimPlugin.messageListener(sendInfo)
+      }
+      //接受任何消息都说明当前连接是正常的
+      this.reset()
+    })
+  }
+
+  /**
+   * 发送状态
+   * @param value
+   */
+  send(value: string): void {
+    this.socket?.send({
+      data: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)
+    }
+    this.callback(message)
+  }
+
+  /**
+   * 发送真正的聊天消息
+   * @param message 消息
+   */
+  sendMessage(message: Message): void {
+    const sendInfo = {
+      code: SendCode.MESSAGE,
+      message: message
+    }
+    this.send(JSON.stringify(sendInfo))
+  }
+
+  /**
+   * 发送已读取消息
+   * @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)
+  }
+
+
+  /**
+   *  立即验证连接有效性
+   *  重置心跳检测和重连检测
+   *  立刻发送一个心跳信息
+   *  如果没有收到消息,就会执行重连
+   */
+  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)
+  }
+  /**
+   * 收到消息的回调函数
+   */
+  callback = (message: Message) => {
+  }
+  // 重连
+  reconnect(): void {
+    // 防止多个方法调用,多处重连
+    if (this.lockReconnect) {
+      return
+    }
+    this.lockReconnect = true
+    //没连接上会一直重连,设置延迟避免请求过多
+    this.reconnectTimeoutTask = setTimeout(() => {
+      // 重新连接
+      this.init()
+      this.lockReconnect = false
+    }, this.timeoutError)
+  }
+
+  // 手动关闭
+  close(closeByUser:boolean): void {
+    this.lockReconnect = false
+    //主动关闭
+    if (this.heartTask) {
+      clearTimeout(this.heartTask)
+    }
+    if (this.reconnectTimeoutTask) {
+      clearTimeout(this.reconnectTimeoutTask)
+    }
+    this.closeByUser = closeByUser
+    if (this.socket) {
+      this.socket.close({ code: 1000, reason: '用户主动关闭' })
+    }
+  }
+}
+
+export default WsRequest

+ 184 - 0
src/colorui/animation.css

@@ -0,0 +1,184 @@
+/* 
+  Animation 微动画  
+  基于ColorUI组建库的动画模块 by 文晓港 2019年3月26日19:52:28
+ */
+
+/* css 滤镜 控制黑白底色gif的 */
+.gif-black{  
+  mix-blend-mode: screen;  
+}
+.gif-white{  
+  mix-blend-mode: multiply; 
+}
+
+
+/* Animation css */
+[class*=animation-] {
+    animation-duration: .5s;
+    animation-timing-function: ease-out;
+    animation-fill-mode: both
+}
+
+.animation-fade {
+    animation-name: fade;
+    animation-duration: .8s;
+    animation-timing-function: linear
+}
+
+.animation-scale-up {
+    animation-name: scale-up
+}
+
+.animation-scale-down {
+    animation-name: scale-down
+}
+
+.animation-slide-top {
+    animation-name: slide-top
+}
+
+.animation-slide-bottom {
+    animation-name: slide-bottom
+}
+
+.animation-slide-left {
+    animation-name: slide-left
+}
+
+.animation-slide-right {
+    animation-name: slide-right
+}
+
+.animation-shake {
+    animation-name: shake
+}
+
+.animation-reverse {
+    animation-direction: reverse
+}
+
+@keyframes fade {
+    0% {
+        opacity: 0
+    }
+
+    100% {
+        opacity: 1
+    }
+}
+
+@keyframes scale-up {
+    0% {
+        opacity: 0;
+        transform: scale(.2)
+    }
+
+    100% {
+        opacity: 1;
+        transform: scale(1)
+    }
+}
+
+@keyframes scale-down {
+    0% {
+        opacity: 0;
+        transform: scale(1.8)
+    }
+
+    100% {
+        opacity: 1;
+        transform: scale(1)
+    }
+}
+
+@keyframes slide-top {
+    0% {
+        opacity: 0;
+        transform: translateY(-100%)
+    }
+
+    100% {
+        opacity: 1;
+        transform: translateY(0)
+    }
+}
+
+@keyframes slide-bottom {
+    0% {
+        opacity: 0;
+        transform: translateY(100%)
+    }
+
+    100% {
+        opacity: 1;
+        transform: translateY(0)
+    }
+}
+
+@keyframes shake {
+
+    0%,
+    100% {
+        transform: translateX(0)
+    }
+
+    10% {
+        transform: translateX(-9px)
+    }
+
+    20% {
+        transform: translateX(8px)
+    }
+
+    30% {
+        transform: translateX(-7px)
+    }
+
+    40% {
+        transform: translateX(6px)
+    }
+
+    50% {
+        transform: translateX(-5px)
+    }
+
+    60% {
+        transform: translateX(4px)
+    }
+
+    70% {
+        transform: translateX(-3px)
+    }
+
+    80% {
+        transform: translateX(2px)
+    }
+
+    90% {
+        transform: translateX(-1px)
+    }
+}
+
+@keyframes slide-left {
+    0% {
+        opacity: 0;
+        transform: translateX(-100%)
+    }
+
+    100% {
+        opacity: 1;
+        transform: translateX(0)
+    }
+}
+
+@keyframes slide-right {
+    0% {
+        opacity: 0;
+        transform: translateX(100%)
+    }
+
+    100% {
+        opacity: 1;
+        transform: translateX(0)
+    }
+}

+ 102 - 0
src/colorui/components/cu-custom.vue

@@ -0,0 +1,102 @@
+<template>
+  <view class="cu-custom"  style="z-index:99999">
+    <view class="cu-bar fixed" :style="style" :class="[bgImage!=''?'none-bg text-white bg-img':'',bgColor]">
+      <view class="action border-custom" v-if="isBack" style="width:160rpx;">
+        <text class="cuIcon-back text-white" @tap="back"></text>
+        <text class="cuIcon-homefill text-white" @tap="toHome"></text>
+      </view>
+      <view class="action" v-if="!isBack" style="width:160rpx;">
+
+      </view>
+      <view class="content" :style="[{top:statusBar + 'px'}]">
+        <slot name="content"></slot>
+      </view>
+      <view class="action right" style="width:160rpx;">
+        <slot name="right"></slot>
+      </view>
+    </view>
+  </view>
+  <!-- #ifdef APP-PLUS|H5 -->
+  <view :style="[{height:customBar*2 + 'upx'}]"></view>
+  <!-- #endif -->
+  <!-- #ifdef MP -->
+  <view :style="[{height:(customBar + 13) + 'px'}]"></view>
+  <!-- #endif -->
+</template>
+
+<script setup>
+import {useChatStore} from "@/store/chatStore";
+import {computed, getCurrentInstance, ref} from "vue";
+
+const {proxy} = getCurrentInstance();
+const chatStore = useChatStore();
+const statusBar = ref(proxy.statusBar);
+const customBar = ref(proxy.customBar);
+
+const props = defineProps({
+  bgColor: {
+    type: String,
+    default: ''
+  },
+  isBack: {
+    type: [Boolean, String],
+    default: false
+  },
+  isShare: {
+    type: [Boolean, String],
+    default: true
+  },
+  bgImage: {
+    type: String,
+    default: ''
+  },
+});
+
+const style = computed(() => {
+  // #ifdef MP
+  let st = `height:${customBar.value*2}rpx;padding-top:${statusBar.value}px;`;
+  // // #endif
+  // #ifndef MP
+  let st = `height:${customBar.value*2}upx;padding-top:${statusBar.value}upx;`;
+  // // #endif
+  if (props.bgImage) {
+    st = `${st}background-image:url(${props.bgImage});`;
+  }
+  return st;
+})
+
+const back = () => {
+  // #ifdef H5
+  let canBack = true
+  const pages = getCurrentPages()
+  if ('pages/index/index' === pages[0].route) {
+    chatStore.setOpenChatId(null);
+  }
+  // 有可返回的页面则直接返回,uni.navigateBack默认返回失败之后会自动刷新页面 ,无法继续返回
+  if (pages.length > 1) {
+    uni.navigateBack()
+    return;
+  }
+  // #endif
+  uni.navigateBack()
+}
+
+const toHome = () => {
+  uni.reLaunch({
+    url: "/pages/index/index"
+  })
+}
+</script>
+
+<style scoped>
+.cu-bar {
+  background-size: cover;
+}
+
+.cu-bar .right {
+  justify-content: flex-end;
+}
+.border-custom{
+	height:70%!important;
+}
+</style>

File diff suppressed because it is too large
+ 1226 - 0
src/colorui/icon.css


File diff suppressed because it is too large
+ 3926 - 0
src/colorui/main.css


+ 79 - 0
src/components/ChatSetting.vue

@@ -0,0 +1,79 @@
+<template>
+  <view class='cu-list menu margin-top'>
+    <!--只有在聊天列表的chat 才有置顶功能-->
+    <view class='cu-item' v-if='chat'>
+      <view class='content'>
+        <text class='cuIcon-messagefill text-grey'></text>
+        <text class='text-grey'>聊天置顶</text>
+      </view>
+      <view class='action'>
+        <switch @change='chatTopChange' :class="isTop?'checked':''" :checked='isTop?true:false'></switch>
+      </view>
+    </view>
+    <view class='cu-item'>
+      <view class='content'>
+        <text class='cuIcon-settings text-grey'></text>
+        <text class='text-grey'>消息免打扰</text>
+      </view>
+      <view class='action'>
+        <switch @change='messageTipsChange' :class="isTips?'checked':''" :checked='isTips?true:false'></switch>
+      </view>
+    </view>
+  </view>
+</template>
+<script lang='ts' setup>
+import {computed, ref} from 'vue'
+import {storeToRefs} from 'pinia'
+import {useImmunityStore} from '@/store/immunityStore'
+import {useChatStore} from '@/store/chatStore'
+import {useUserStore} from '@/store/userStore'
+import ImmunityApi from '@/api/ImmunityApi'
+
+
+interface IProps{
+  chatId:string
+}
+const props = defineProps<IProps>();
+const isTop = ref(false)
+const chatStore = useChatStore()
+const immunityStore = useImmunityStore()
+const { immunityList } = storeToRefs(immunityStore)
+const isTips = computed(() => {
+  return immunityList.value.indexOf(props.chatId) > -1
+})
+
+const chat = chatStore.getChat(props.chatId)
+if (chat) {
+  isTop.value = chat.top ?? false
+}
+
+const chatTopChange = () => {
+  if(chat){
+    isTop.value = !isTop.value
+    if (isTop.value) {
+      chatStore.topChat(props.chatId)
+    } else {
+      chatStore.cancelTop(props.chatId)
+    }
+  }
+}
+
+const messageTipsChange = (e: TouchEvent) => {
+  const userId = useUserStore().getUser()?.id
+  if (userId) {
+    // @ts-ignore
+    if (e.detail.value) {
+      ImmunityApi.save(userId, props.chatId).then(() => {
+        immunityStore.loadData()
+      })
+    } else {
+      ImmunityApi.delete(userId, props.chatId).then(() => {
+        immunityStore.loadData()
+      })
+    }
+  }
+}
+</script>
+<style scoped>
+
+</style>

+ 47 - 0
src/components/Faces.vue

@@ -0,0 +1,47 @@
+<template>
+	<view>
+		<view class="faces">
+			<view class="face" v-for="(item, index) in faceList" :key="index">
+				<image class="image" :src="faceMap.get(item)" :alt="item" :title="item" @tap="insertFace(item)" />
+			</view>
+		</view>
+		<div class="clear"></div>
+	</view>
+</template>
+
+<script setup lang="ts">
+import FaceUtils from "@/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 lang="scss" scoped>
+	.faces {
+		width: 100%;
+		list-style: none;
+		background-color: #ffffff;
+		border: 1px solid #f0f5ff;
+		display: grid;
+		grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
+
+		&>.face {
+			display: flex;
+			padding: 4px;
+			float: left;
+			cursor: pointer;
+			height: 37px;
+			width: 37px;
+			justify-content: center;
+			.image {
+				width: 100%;
+				height: 100%;
+			}
+		}
+	}
+</style>

+ 77 - 0
src/components/HandleQuoteMessage.vue

@@ -0,0 +1,77 @@
+<template>
+  <view v-if="message && user" class="message-box">
+    <view v-if="message.messageType === MessageType.text" class="message">
+      {{ user.name }}:{{ message.content }}
+    </view>
+    <view v-if="message.messageType === MessageType.image" class="message-img">
+      <text>{{ user.name }}:</text>
+      <image alt="图片" :src="message.extend?.url" style="height: 60rpx;width: 60rpx" />
+    </view>
+    <view v-if="message.messageType === MessageType.file" class="message">
+      {{ user.name }}:{{ getFileName(message.extend?.fileName) }}
+    </view>
+    <view v-if="message.messageType === MessageType.video" class="message">
+      {{ user.name }}:<text class="cuIcon cuIcon-video"></text>{{getFileName(message.extend?.url)}}
+    </view>
+    <view v-if="message.messageType === MessageType.voice" class="message">
+      {{ user.name }}:<text class="cuIcon-sound"></text>{{getFileName(message.extend?.time)}}
+    </view>
+    <text class="cuIcon cuIcon-close" @tap="closeQuote"></text>
+  </view>
+</template>
+<script setup lang="ts">
+import { computed } from 'vue'
+import MessageType from '../utils/MessageType'
+import { storeToRefs } from 'pinia'
+import { useUserStore } from '@/store/userStore'
+import {getFileName}  from '@/utils/FileUtils'
+import type Message from "@/mode/Message";
+import {useChatStore} from "@/store/chatStore";
+
+const { userMap } = storeToRefs(useUserStore())
+interface IProps{
+  message:Message
+}
+const props = defineProps<IProps>();
+
+const user = computed(() => {
+  if(props.message){
+    return userMap.value.get(props.message.fromId)
+  }
+  return null;
+})
+
+const closeQuote = () => {
+  useChatStore().setQuoteMessage(undefined)
+}
+</script>
+<style scoped lang="scss">
+.message-box {
+  cursor: pointer;
+  flex: 1;
+  overflow: hidden;
+  background-color: #e9e9e9;
+  margin-top: 10px;
+  padding: 5px 11px;
+  color: #999999;
+  font-size: 24rpx;
+  width: 60vw;
+  display: flex;
+  align-items: center;
+  .message {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    display: inline-block;
+    max-width: 100%;
+    flex: 1;
+  }
+  .message-img {
+    line-height: 2;
+    white-space: nowrap;
+    display: inline-flex;
+    padding: 0 11px;
+    max-width: 620px;
+  }
+}
+</style>

+ 161 - 0
src/components/IndexBar.vue

@@ -0,0 +1,161 @@
+<template>
+  <view>
+    <scroll-view scroll-y class="indexes" :scroll-into-view="'indexes-'+ listCurID"
+                 :style="[{height:height}]" :scroll-with-animation="true"
+                 :enable-back-to-top="true" v-if="indexMap">
+      <slot></slot>
+    </scroll-view>
+    <view class="indexBar" :style="[{height:height}]">
+      <view class="indexBar-box" @touchstart="tStart" @touchend="tEnd" @touchmove.stop="tMove">
+        <view class="indexBar-item" v-for="(item) in indexMap.keys()" :key="item" :id="item"
+              @touchstart="getCur" @touchend="setCur"> {{ item }}
+        </view>
+      </view>
+    </view>
+    <!--选择显示-->
+    <view v-show="!hidden" class="indexToast">
+      {{ listCur }}
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, ref} from "vue";
+import {useGroupStore} from "@/store/groupStore";
+import type ChatSimple from "@/mode/ChatSimple";
+
+const groupStore = useGroupStore();
+const {proxy} = getCurrentInstance();
+const customBar = ref(proxy.customBar);
+const hidden = ref(true);
+const listCurID = ref('');
+const listCur = ref('');
+const height = computed(() => {
+  return 'calc(100vh - ' + customBar.value + 'px ' + (props.bottom ? '- 50px' : '') + ')';
+})
+
+const props = defineProps(
+    {
+      //索引map
+      indexMap: {
+        type: Map,
+        default: new Map<string, ChatSimple>()
+      },
+      //是否展示底部高度
+      bottom: {
+        type: Boolean,
+        default: false
+      }
+    }
+)
+
+
+//获取文字信息
+const getCur = (e: any) => {
+  hidden.value = false;
+  listCur.value = e.target.id;
+}
+
+const setCur = () => {
+  hidden.value = true;
+}
+
+//滑动选择Item
+const tMove = (e: TouchEvent) => {
+  let y = e.touches[0].clientY,
+      offsettop = customBar.value;
+  //判断选择区域,只有在选择区才会生效
+  if (y > offsettop) {
+    let num = (y - offsettop) / 20;
+    listCur.value = props.indexMap.get(num)?.name
+  }
+}
+
+//触发全部开始选择
+const tStart = () => {
+  hidden.value = false
+}
+
+//触发结束选择
+const tEnd = () => {
+  hidden.value = true;
+  listCurID.value = listCur.value
+}
+
+const showGroup = (id: string) => {
+  uni.navigateTo({
+    url: '/pages/group/group?id=' + id
+  })
+}
+
+
+</script>
+
+<style>
+.indexes {
+  position: relative;
+}
+
+.indexBar {
+  position: fixed;
+  right: 0px;
+  bottom: 0px;
+  padding: 20upx 20upx 20upx 60upx;
+  display: flex;
+  align-items: center;
+}
+
+.indexBar .indexBar-box {
+  width: 40upx;
+  height: auto;
+  background: #fff;
+  display: flex;
+  flex-direction: column;
+  box-shadow: 0 0 20upx rgba(0, 0, 0, 0.1);
+  border-radius: 10upx;
+}
+
+.indexBar-item {
+  flex: 1;
+  width: 40upx;
+  height: 40upx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24upx;
+  color: #888;
+}
+
+.indexBar-item {
+  width: 40upx;
+  height: 40upx;
+  z-index: 9;
+  position: relative;
+}
+
+.indexBar-item::before {
+  content: "";
+  display: block;
+  position: absolute;
+  left: 0;
+  height: 20upx;
+  width: 4upx;
+  background-color: #f37b1d;
+}
+
+.indexToast {
+  position: fixed;
+  top: 0;
+  right: 80upx;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  width: 100upx;
+  height: 100upx;
+  border-radius: 10upx;
+  margin: auto;
+  color: #fff;
+  line-height: 100upx;
+  text-align: center;
+  font-size: 48upx;
+}
+</style>

+ 17 - 0
src/components/LoadMapUser.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import {storeToRefs} from "pinia";
+import {useUserStore} from "@/store/userStore";
+
+interface IProps {
+  userId: string
+}
+
+const props = defineProps<IProps>();
+const {userMap} = storeToRefs(useUserStore())
+if (userMap.value.get(props.userId) === undefined) {
+  useUserStore().loadMapUser(props.userId)
+}
+</script>
+<template>
+
+</template>

+ 184 - 0
src/components/MessageView.vue

@@ -0,0 +1,184 @@
+<template>
+  <zb-tooltip placement="top" ref="tooltip" content="信息提示" color="#ccc">
+    <template #content>
+      <view class="menu">
+        <view class="menu-item" @tap="handleToOther">转发</view>
+        <view class="menu-item"  @tap="handleQuote">引用</view>
+        <view class="menu-item"  @tap="handleMultiple">多选</view>
+        <view class="menu-item"  @tap="handleCollect">收藏</view>
+        <view class="menu-item"  v-if="item.messageType === MessageType.text" @tap="handleClipboard">复制</view>
+        <view class="menu-item"  v-if="item.messageType === MessageType.file" @tap="handleOpen">打开</view>
+        <view class="menu-item"  v-if="timeShow" @tap="messageBack">撤回</view>
+        <view class="menu-item"  v-if="item.messageType === MessageType.image" @tap="handleDownload">下载</view>
+      </view>
+    </template>
+    <!-- #ifndef MP-WEIXIN -->
+    <component :is="useMessageComponent(item.messageType)" :message="item"></component>
+    <!-- #endif -->
+    <!-- #ifdef MP-WEIXIN -->
+    <message-text v-if="item.messageType === MessageType.text" :message="item"></message-text>
+    <message-image v-if="item.messageType === MessageType.image" :message="item"></message-image>
+    <message-file v-if="item.messageType === MessageType.file" :message="item"></message-file>
+    <message-voice v-if="item.messageType === MessageType.voice" :message="item"></message-voice>
+    <message-video v-if="item.messageType === MessageType.video" :message="item"></message-video>
+    <message-multiple-forward v-if="item.messageType === MessageType.forward" :message="item"></message-multiple-forward>
+    <!-- #endif -->
+  </zb-tooltip>
+</template>
+
+<script setup lang="ts">
+import {ref} from "vue";
+import MessageType from "@/utils/MessageType";
+import {useChatStore} from "@/store/chatStore";
+import CollectApi from "@/api/CollectApi";
+import MessageUtils from "@/utils/MessageUtils";
+import ZbTooltip from "@/uni_modules/zb-tooltip/components/zb-tooltip/zb-tooltip.vue";
+import {useWsStore} from "@/store/WsStore";
+import type Message from "@/mode/Message";
+import type Collect from "@/mode/Collect";
+// #ifndef MP-WEIXIN
+import useMessageComponent from "@/hooks/useMessageComponent";
+// #endif
+import useClipboard from 'vue-clipboard3'
+import {useMessageStore} from "@/store/messageStore";
+import MessageText from '@/components/messages/MessageText.vue'
+import MessageImage from '@/components/messages/MessageImage.vue'
+import MessageFile from '@/components/messages/MessageFile.vue'
+import MessageVoice from '@/components/messages/MessageVoice.vue'
+import MessageVideo from '@/components/messages/MessageVideo.vue'
+import MessageMultipleForward from "@/components/messages/MessageMultipleForward.vue";
+const {toClipboard} = useClipboard()
+const wsStore = useWsStore();
+const chatStore = useChatStore();
+const messageStore = useMessageStore();
+const emit = defineEmits(['toOtherHandle'])
+const tooltip = ref();
+
+interface IProps {
+  item: Message
+}
+
+let props = defineProps<IProps>();
+
+
+//转发
+const handleToOther = () => {
+  tooltip.value.close();
+  messageStore.setCheckList([props.item])
+  uni.navigateTo({
+    url: `/pages/chat/send-other`
+  })
+}
+
+const handleClipboard = () => {
+  tooltip.value.close();
+  const message = props.item;
+  toClipboard(message.content).then(() => {
+    MessageUtils.message("复制成功");
+  })
+}
+
+const handleOpen = () => {
+  tooltip.value.close();
+  const message = props.item;
+  const url = message.extend?.url
+  if (url) {
+    uni.downloadFile({
+      url: url, //仅为示例,并非真实的资源
+      success: (res) => {
+        if (res.statusCode === 200) {
+          uni.openDocument({
+            filePath: res.tempFilePath,
+            showMenu: true
+          });
+        }
+      }
+    })
+  }
+}
+
+
+const handleDownload = () => {
+  tooltip.value.close();
+  const message = props.item;
+  const url = message.extend?.url
+  if (url) {
+    uni.downloadFile({
+      url: url, //仅为示例,并非真实的资源
+      success: (res) => {
+        if (res.statusCode === 200) {
+          uni.saveImageToPhotosAlbum({
+            filePath: res.tempFilePath,
+            success: () => {
+              MessageUtils.message("已经成功下载到相册");
+            }
+          });
+        }
+      }
+    });
+  }
+}
+
+
+//收藏
+const handleCollect = () => {
+  tooltip.value.close();
+  const message = props.item;
+  const collect: Collect = {
+    fromId: message.fromId,
+    content: message.content,
+    messageType: message.messageType,
+    extend: message.extend === undefined ? "" : JSON.stringify(message.extend),
+    sendTime: message.timestamp,
+  };
+  CollectApi.save(collect).then(() => {
+    MessageUtils.message("收藏成功");
+  });
+}
+
+//引用
+const handleQuote = () => {
+  tooltip.value.close();
+  chatStore.setQuoteMessage(props.item);
+}
+//多选
+const handleMultiple = () => {
+  tooltip.value.close();
+  useMessageStore().setShowMultipleCheck(true)
+}
+
+const timeShow = ref(false);
+const interval = setInterval(() => {
+  if (props.item && props.item.timestamp && props.item.mine) {
+    timeShow.value = props.item.mine && ((props.item.timestamp + 2 * 60 * 1000) > new Date().getTime())
+    if (!timeShow.value) {
+      clearInterval(interval);
+    }
+  }
+}, 1000);
+
+
+/**
+ * 消息撤回
+ */
+const messageBack = () => {
+  tooltip.value.close();
+  const message = JSON.parse(JSON.stringify(props.item));
+  message.messageType = MessageType.back;
+  message.content = "该消息已被撤回";
+  wsStore.sendMessage(message);
+};
+
+</script>
+
+<style scoped lang="scss">
+.menu {
+  display: flex;
+  flex-wrap: wrap;
+  width: 40vw;
+  .menu-item {
+    width: 8vw;
+    padding: 5px;
+  }
+}
+</style>

+ 111 - 0
src/components/MultipleForward.vue

@@ -0,0 +1,111 @@
+<template>
+  <view class="flex forward">
+    <view class="flex-sub" @tap="handleCloseMultiple">
+      <text class="cuIcon-close text-xxl"></text>
+      <view>取消</view>
+    </view>
+    <view class="flex-sub" @tap="sendMultiple">
+      <text class="cuIcon-forward text-xxl"></text>
+      <view>合并转发</view>
+    </view>
+    <view class="flex-sub" @tap="sendSingle">
+      <text class="cuIcon-forwardfill text-xxl"></text>
+      <view>逐条转发</view>
+    </view>
+    <view class="flex-sub" @tap="storeMessage">
+      <text class="cuIcon-favor text-xxl"></text>
+      <view>收藏</view>
+    </view>
+  </view>
+</template>
+<script setup lang="ts">
+import {useMessageStore} from "@/store/messageStore";
+import {storeToRefs} from "pinia";
+import MessageUtils from "@/utils/MessageUtils";
+import type Collect from "@/mode/Collect";
+import CollectApi from "@/api/CollectApi";
+import {onUnload} from "@dcloudio/uni-app";
+
+const messageStore = useMessageStore()
+
+const { checkList } = storeToRefs(messageStore)
+/**
+ * 关闭多选
+ */
+const handleCloseMultiple = () => {
+  messageStore.setShowMultipleCheck(false)
+  messageStore.setCheckList([])
+}
+
+/**
+ * 组件销毁,清空选中
+ */
+onUnload(() => {
+  handleCloseMultiple()
+})
+
+/**
+ * 合并转发
+ */
+const sendMultiple = (): void => {
+  if (checkList.value.length > 0) {
+    uni.navigateTo({
+      url: `/pages/chat/send-other?multiple=true`
+    })
+  } else {
+    MessageUtils.message('请选择转发的消息')
+  }
+}
+
+/**
+ * 逐条转发
+ */
+const sendSingle = (): void => {
+  if (checkList.value.length > 0) {
+    uni.navigateTo({
+      url: `/pages/chat/send-other`
+    })
+  } else {
+    MessageUtils.message('请选择转发的消息')
+  }
+}
+
+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()
+  MessageUtils.message('收藏成功')
+}
+
+</script>
+
+
+<style scoped lang="scss">
+.forward{
+  display: flex;
+  width: 100vw;
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  z-index: 999999;
+  background-color: #ffffff;
+  padding: 10px;
+  view{
+    color: #999999;
+    text-align: center;
+    font-size: 24upx;
+    .text-xxl{
+      font-size: 40upx;
+    }
+  }
+}
+</style>

+ 36 - 0
src/components/NoData.vue

@@ -0,0 +1,36 @@
+<template>
+  <view class="no-data">
+    <view class="no-data-img">
+      <text class="cuIcon cuIcon-info"></text>
+    </view>
+    <view class="no-data-text">暂无数据</view>
+  </view>
+</template>
+<script setup lang="ts">
+
+</script>
+<style scoped>
+  .no-data {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    height: 60vh;
+  }
+
+  .no-data-img {
+    width: 200rpx;
+    height: 200rpx;
+  }
+
+  .cuIcon-info{
+    font-size: 200rpx;
+    color: #999999;
+  }
+
+  .no-data-text {
+    font-size: 30rpx;
+    color: #999999;
+    line-height: 90rpx;
+  }
+</style>

+ 78 - 0
src/components/QuoteMessage.vue

@@ -0,0 +1,78 @@
+<template>
+  <view v-if="quoteMessage && user" class="message-box" @click='scrollTo'>
+    <view v-if="quoteMessage.messageType === MessageType.text" class="message">
+      {{ user.name }}:{{ quoteMessage.content }}
+    </view>
+    <view v-if="quoteMessage.messageType === MessageType.image" class="message-img">
+      <text>{{ user.name }}:</text>
+      <image alt="图片" :src="quoteMessage.extend?.url" style="height: 60rpx;width: 60rpx" />
+    </view>
+    <view v-if="quoteMessage.messageType === MessageType.file" class="message">
+      <text>{{ user.name }}:</text>{{ getFileName(quoteMessage.extend?.fileName) }}
+    </view>
+    <view v-if="quoteMessage.messageType === MessageType.video" class="message-img">
+      <text>{{ user.name }}:</text>
+      <video style="height: 100%">
+        <source :src="quoteMessage.extend?.url" type="video/mp4" />
+      </video>
+    </view>
+    <view v-if="quoteMessage.messageType === MessageType.voice" class="message">
+      <view>
+        <text>{{ user.name }}:</text>
+        <text class="cuIcon-sound text-xxl" :class="{ 'icon-v-voice-right': quoteMessage.mine }"></text>
+      </view>
+    </view>
+  </view>
+</template>
+<script setup lang="ts">
+import {ref} from 'vue'
+import MessageType from '../utils/MessageType'
+import {storeToRefs} from 'pinia'
+import {useUserStore} from '@/store/userStore'
+import {getFileName} from '@/utils/FileUtils'
+import type Message from "@/mode/Message";
+import {last} from "@/hooks/useMessageImageLoad";
+import type 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 = () => {
+  last.value = 'm-' + props.message.extend?.quoteMessage?.id
+}
+
+</script>
+<style scoped lang="scss">
+.message-box {
+  cursor: pointer;
+  flex: 1;
+  overflow: hidden;
+  background-color: #e9e9e9;
+  margin-top: 10px;
+  padding: 5px 11px;
+  color: #999999;
+  font-size: 24rpx;
+  .message {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    display: inline-block;
+    max-width: 100%;
+  }
+  .message-img {
+    line-height: 30px;
+    white-space: nowrap;
+    display: inline-flex;
+    padding: 0 11px;
+    max-width: 620px;
+  }
+}
+</style>

+ 50 - 0
src/components/Time.vue

@@ -0,0 +1,50 @@
+<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') {
+    //@ts-ignore
+    const timestamp = props.time.toString().length > 10 ? props.time : props.time * 1000;
+    time = (new Date(timestamp)).getTime();
+  } else if (type === 'object') {
+    //@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>

+ 39 - 0
src/components/UserAvatar.vue

@@ -0,0 +1,39 @@
+<template>
+	<view class="item">
+		<view class="img">
+      <vim-avatar :img="chat.avatar" :name="chat.name" />
+		</view>
+		<view class="name">{{chat.name}}</view>
+	</view>
+</template>
+
+<script setup lang="ts">
+import type Chat from "@/mode/Chat";
+import VimAvatar from "@/components/VimAvatar.vue";
+
+interface IProps{
+  chat:Chat
+}
+let props = defineProps<IProps>();
+</script>
+
+<style scoped lang="scss">
+	.item {
+		display: inline-block;
+		padding: 10upx;
+
+		.img {
+			text-align: center;
+		}
+
+		.name {
+			text-align: center;
+			margin-top: 10upx;
+			width: 100%;
+			white-space: nowrap;
+			overflow: hidden;
+			text-overflow: ellipsis;
+      line-height: 1rem;
+		}
+	}
+</style>

+ 27 - 0
src/components/UserAvatarTag.vue

@@ -0,0 +1,27 @@
+<template>
+  <image :src="avatar" class="cu-avatar radius lg"></image>
+</template>
+
+<script setup lang="ts">
+import {ref} from "vue";
+import {useUserStore} from "@/store/userStore";
+import UserApi from "@/api/UserApi";
+
+const avatar = ref("");
+const props = defineProps({
+  id: {
+    type: String,
+    default: "",
+  },
+});
+const userStore = useUserStore();
+const user = userStore.getCacheUser(props.id);
+user
+  ? (avatar.value = user.name)
+  : UserApi.getUser(props.id).then((res) => {
+      avatar.value = res?.data?.avatar;
+      userStore.storeUser(res.data.id,res.data);
+    });
+</script>
+
+<style scoped></style>

+ 27 - 0
src/components/UserNameTag.vue

@@ -0,0 +1,27 @@
+<template>
+  <text>{{ username }}</text>
+</template>
+
+<script setup lang="ts">
+import {ref} from "vue";
+import {useUserStore} from "@/store/userStore";
+import UserApi from "@/api/UserApi";
+
+const username = ref("");
+const props = defineProps({
+  id: {
+    type: String,
+    default: "",
+  },
+});
+const userStore = useUserStore();
+const user = userStore.getMapUser(props.id);
+user
+  ? (username.value = user.name)
+  : UserApi.getUser(props.id).then((res) => {
+      username.value = res?.data?.name;
+      userStore.setUser(res?.data)
+    });
+</script>
+
+<style scoped></style>

+ 79 - 0
src/components/VimAvatar.vue

@@ -0,0 +1,79 @@
+<template>
+  <image v-if="url" :src="url" class="cu-avatar radius" :class="[size,{'grid-image':isGroup}]" ></image>
+  <view v-if="!url" class="cu-avatar radius avatar-name bg-olive" :class="[size,{'grid-image':isGroup}]">{{start}}</view>
+</template>
+
+<script setup lang="ts">
+import {computed, ref} from "vue";
+import FetchRequest from "@/api/FetchRequest";
+const host = ref(FetchRequest.getHost())
+
+interface IProps {
+  size?: string
+  img?: string
+  name?: string
+  isGroup?: boolean
+}
+const props = withDefaults(defineProps<IProps>(), {
+  size: 'lg' ,
+  img: '',
+  name: '',
+  isGroup: false,
+})
+const url = computed(() => {
+  if (props.img?.indexOf('http') > -1) {
+    return props.img
+  } else if (props.img?.trim() === '') {
+    return null
+  } else {
+    return host.value + props.img
+  }
+})
+const start = computed(() => {
+  return props.name ? props.name.slice(0,2) : ''
+})
+</script>
+<style scoped>
+.avatar-name{
+  font-size: 1rem;
+  font-weight: bold;
+}
+.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(51% - 1px);
+  bottom: calc(51% - 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>

+ 268 - 0
src/components/VimUpload.vue

@@ -0,0 +1,268 @@
+<template>
+	<view>
+		<view class="flex bg-white">
+			<view class="flex-sub padding" @tap="chooseImage">
+				<text class="cuIcon-pic text-grey text-xxl"></text>
+				<view class="text-center text-xs">发送图片</view>
+			</view>
+			<!-- #ifndef APP-PLUS -->
+			<!-- <view class="flex-sub padding" @tap="chooseVideo">
+				<text class="cuIcon-video text-grey text-xxl"></text>
+				<view class="text-center text-xs">发送视频</view>
+			</view> -->
+			<!-- <view class="flex-sub padding" @tap="chooseFile">
+				<text class="cuIcon-file text-grey text-xxl"></text>
+				<view class="text-center text-xs">发送文件</view>
+			</view> -->
+			<!-- #endif -->
+			<!-- #ifdef APP-PLUS -->
+			<!-- <view class="flex-sub padding" @tap="chooseAppFile">
+				<text class="cuIcon-file text-grey text-xxl"></text>
+				<view class="text-center text-xs">发送文件</view>
+			</view> -->
+			<view v-if="chatType===ChatType.FRIEND" v-for="(item) in VimPlugin.chatViewPlugins()"
+				class="flex-sub padding" @tap="item.handle(chatId)">
+				<text class="text-grey text-xxl" :class="item.icon"></text>
+				<view class="text-center text-xs">{{item.title}}</view>
+			</view>
+			<!-- #endif -->
+		</view>
+	</view>
+</template>
+
+<script setup lang="ts">
+	import Auth from '@/api/Auth';
+	import FetchRequest from '@/api/FetchRequest';
+	import MessageType from '@/utils/MessageType';
+	import authorizeUtils from '@/store/authorizeUtils';
+	//#ifdef APP-PLUS
+	import filePicker from "@/uni_modules/file-picker"
+	//#endif
+	import { getFileType } from "@/utils/FileUtils";
+	import MessageUtils from "@/utils/MessageUtils";
+	import type Message from "@/mode/Message";
+	import { useWsStore } from "@/store/WsStore";
+	import { useUserStore } from "@/store/userStore";
+	import VimPlugin from "@/plugins/VimPlugin";
+	import ChatType from "@/utils/ChatType";
+	import VimConfig from "@/config/VimConfig";
+	import {
+		reactive
+	} from 'vue'
+	const data = reactive({
+		show: false,
+	});
+
+	const props = defineProps({
+		chatId: {
+			type: String,
+			required: true,
+			default: ''
+		},
+		chatType: {
+			type: String,
+			required: true,
+			default: ''
+		}
+	})
+	const userId = useUserStore().getUser()?.id
+
+	const chooseFile = () => {
+		//#ifdef H5
+		uni.chooseFile({
+			count: 1,
+			extension: ['.zip', '.doc', '.docx', '.pdf', '.xls', '.xlsx'],
+			success: function (res) {
+				upload(res.tempFilePaths[0], MessageType.file, res.tempFiles[0].name)
+			}
+		});
+		//#endif
+		//#ifdef MP
+		wx.chooseMessageFile({
+			count: 1,
+			type: 'file',
+			success(res : any) {
+				// tempFilePath可以作为img标签的src属性显示图片
+				upload(res.tempFiles[0].path, MessageType.file, res.tempFiles[0].name)
+			}
+		})
+		//#endif
+	}
+	//#ifdef APP-PLUS
+	const chooseAppFile = () => {
+		filePicker({
+			scope: "/Download",
+			permission: false,
+			mimetype: "*/*",
+			success(res : any) {
+				const fileType = getFileType(res.fileName).toLowerCase()
+				if (fileType === "mp4") {
+					upload(res.filePath, MessageType.video, res.fileName)
+				} else if (fileType === "mp3") {
+					upload(res.filePath, MessageType.voice, res.fileName)
+				} else if (['jpg', 'png', 'gif'].some(item => item === fileType)) {
+					upload(res.filePath, MessageType.image, res.fileName)
+				} else {
+					upload(res.filePath, MessageType.file, res.fileName)
+				}
+			},
+			fail(err : any) {
+				console.log(err);
+			}
+		})
+	}
+	//#endif
+	const chooseVideo = async () => {
+		//#ifdef H5
+		let userAgent = navigator.userAgent
+		if (userAgent.match(/huawei/i)) {
+			if(data.show==false){
+				uni.showModal({
+					title: '相机权限说明',
+					content: '用于拍照、录制视频等场景,是否允许此APP拍摄照片和录制视频,请您确认授权,否则无法使用该功能',
+					cancelText:"禁止",
+					confirmText:"允许",
+					success: (res) => {
+						data.show = true;
+						uni.chooseFile({
+							count: 1,
+							extension: ['.mp4'],
+							success: function (res) {
+								upload(res.tempFilePaths[0], MessageType.video, res.tempFiles[0].name)
+							}
+						});
+					},
+					fail: () => {
+				
+					}
+				})
+			}else{
+				uni.chooseFile({
+					count: 1,
+					extension: ['.mp4'],
+					success: function (res) {
+						upload(res.tempFilePaths[0], MessageType.video, res.tempFiles[0].name)
+					}
+				});
+			}
+		} else {
+			uni.chooseFile({
+				count: 1,
+				extension: ['.mp4'],
+				success: function (res) {
+					upload(res.tempFilePaths[0], MessageType.video, res.tempFiles[0].name)
+				}
+			});
+		}
+
+		//#endif
+		// //#ifdef H5
+		// uni.chooseFile({
+		// 	count: 1,
+		// 	extension: ['.mp4'],
+		// 	success: function (res) {
+		// 		upload(res.tempFilePaths[0], MessageType.video, res.tempFiles[0].name)
+		// 	}
+		// });
+		// //#endif
+		//#ifdef MP
+		wx.chooseMessageFile({
+			count: 1,
+			type: 'video',
+			success(res : any) {
+				// tempFilePath可以作为img标签的src属性显示图片
+				upload(res.tempFiles[0].path, MessageType.file, res.tempFiles[0].name)
+			}
+		})
+		//#endif
+	}
+	const chooseImage = async () => {
+		//#ifdef H5
+		let userAgent = navigator.userAgent
+		if (userAgent.match(/huawei/i)) {
+			if(data.show==false){
+				uni.showModal({
+					title: '相册权限说明',
+					content: '便于您使用该功能上传您的照片/图片/视频及用户实名认证信息、发布房源时上传图片,请您确认授权,否则无法使用该功能',
+					success: (res) => {
+						data.show = true;
+						uni.chooseImage({
+							count: 1,
+							sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
+							sourceType: ['album', 'camera'], //从相册选择或者相机
+							success: function (res) {
+								upload(res.tempFilePaths[0], MessageType.image, '')
+							}
+						});
+					},
+					fail: () => {
+				
+					}
+				})
+			}else{
+				uni.chooseImage({
+					count: 1,
+					sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
+					sourceType: ['album', 'camera'], //从相册选择或者相机
+					success: function (res) {
+						upload(res.tempFilePaths[0], MessageType.image, '')
+					}
+				});
+			}
+			
+		} else {
+			uni.chooseImage({
+				count: 1,
+				sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
+				sourceType: ['album', 'camera'], //从相册选择或者相机
+				success: function (res) {
+					upload(res.tempFilePaths[0], MessageType.image, '')
+				}
+			});
+		}
+		//#endif
+	}
+	const upload = (path : string, messageType : string, fileName : string) => {
+		uni.showLoading({
+			title: '上传中'
+		})
+		uni.uploadFile({
+			url: `${FetchRequest.getHost()}/${VimConfig.uploadType}/upload`, //仅为示例,非真实的接口地址
+			filePath: path,
+			name: 'file',
+			header: {
+				"Access-Control-Allow-Origin": "*",
+				"Authorization": "Bearer " + Auth.getToken(),
+			},
+			formData: {
+				'type': messageType
+			},
+			success: (res) => {
+				uni.hideLoading()
+				const data = JSON.parse(res.data)
+				const extend = (messageType === MessageType.file) ? { url: data.url, fileName: fileName } : { url: data.url };
+				if (userId) {
+					let msg : Message = {
+						messageType: messageType,
+						chatId: props.chatId,
+						fromId: userId,
+						content: '',
+						type: props.chatType,
+						extend: extend
+					}
+					useWsStore().sendMessage(msg)
+				}
+			},
+			fail: () => {
+				uni.hideLoading()
+				MessageUtils.message('上传失败')
+			},
+		});
+	}
+</script>
+
+<style scoped>
+	.text-xxl {
+		font-size: 64upx;
+	}
+</style>

+ 149 - 0
src/components/Voice.vue

@@ -0,0 +1,149 @@
+<template>
+  <button class="cu-btn voice-btn" v-if="!disabled" ref="v-btn"
+          :class="voice.recording ? 'active' : ''"
+          @touchstart.stop.prevent="startVoice" @touchmove.stop.prevent="moveVoice" @touchend.stop="endVoice"
+          @touchcancel.stop="cancelVoice">
+    {{ voice.voiceTitle }}
+  </button>
+  <button class="cu-btn" v-if="disabled" ref="v-btn"
+          :style="{ background: voice.recording ? '#c7c6c6' : '#FFFFFF',flex:'1' }">
+    {{ times }}秒后可录音
+  </button>
+</template>
+
+<script setup lang="ts">
+import {onMounted, reactive, ref} from "vue";
+import MessageType from "@/utils/MessageType";
+import upload from '@/api/UploadApi';
+import MessageUtils from "@/utils/MessageUtils";
+
+const disabled = ref(false);
+const times = ref(5);
+const interval = ref(-1);
+const emit = defineEmits(["uploadCallback"]);
+const voice = reactive({
+  voiceTitle: '按住 说话',
+  Recorder: uni.getRecorderManager(),
+  recording: false,
+  //标识是否正在录音
+  isStopVoice: false,
+  //加锁 防止点击过快引起的当录音正在准备(还没有开始录音)的时候,却调用了stop方法但并不能阻止录音的问题
+  voiceInterval: -1,
+  voiceTime: 0, //总共录音时长
+  canSend: true, //是否可以发送
+  PointY: 0, //坐标位置
+  showFunBtn: false, //是否展示功能型按钮
+  AudioExam: null //正在播放音频的实例
+})
+
+onMounted(() => {
+  //录音开始事件
+  voice.Recorder.onStart(() => {
+    beginVoice();
+  });
+  //录音结束事件
+  voice.Recorder.onStop(res => {
+    clearInterval(voice.voiceInterval);
+    if (voice.voiceTime >= 1) {
+      handleRecorder(res);
+    } else {
+      voice.recording = false;
+      MessageUtils.message('录音时间太短')
+    }
+  });
+
+})
+
+
+//准备开始录音
+const startVoice = (e: TouchEvent) => {
+  voice.recording = true;
+  voice.isStopVoice = false;
+  voice.canSend = true;
+  voice.PointY = e.touches[0].clientY;
+
+  voice.Recorder.start({
+    format: 'mp3'
+  });
+}
+//录音已经开始
+const beginVoice = () => {
+  uni.showLoading({
+    title: '正在录音'
+  })
+  voice.voiceTitle = '松开 结束'
+  voice.voiceInterval = setInterval(() => {
+    voice.voiceTime++;
+  }, 1000)
+}
+//move 正在录音中
+const moveVoice = (e: TouchEvent) => {
+  const pointY = e.touches[0].clientY
+  const slideY = voice.PointY - pointY;
+  if (slideY > uni.upx2px(120)) {
+    voice.canSend = false;
+  } else if (slideY > uni.upx2px(60)) {
+    voice.canSend = true;
+  }
+}
+//结束录音
+const endVoice = () => {
+  uni.hideLoading()
+  voice.isStopVoice = true; //加锁 确保已经结束录音并不会录制
+  setTimeout(function () {
+    voice.Recorder.stop();
+  }, 200)
+  voice.voiceTitle = '按住 说话'
+  disabled.value = true;
+  setTimeout(function () {
+    disabled.value = false;
+    clearInterval(interval.value)
+    times.value = 5;
+  }, 5000)
+  interval.value = setInterval(function () {
+    times.value--;
+  }, 1000)
+}
+//录音被打断
+const cancelVoice = () => {
+  voice.voiceTime = 0;
+  voice.voiceTitle = '按住 说话';
+  voice.canSend = false;
+  voice.Recorder.stop();
+}
+//处理录音文件
+const handleRecorder = ({tempFilePath}: any) => {
+  voice.recording = false;
+  uni.showLoading({
+    title: '正在上传'
+  })
+  upload(tempFilePath)
+      .then((res: any) => {
+        const extend = {url: res.url, time: Math.ceil(voice.voiceTime)};
+        voice.voiceTime = 0;
+        emit('uploadCallback', extend, MessageType.voice)
+      })
+      .finally(() => {
+        uni.hideLoading()
+      })
+}
+</script>
+<style scoped>
+.voice-btn {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #fff;
+  padding: 20upx;
+  box-shadow: 0.0625rem 0.0625rem 0.1875rem rgba(0, 0, 0, .2);
+  position: relative;
+  margin: 0 .1875rem !important;
+  height: 100%;
+}
+
+.voice-btn.active {
+  background-color: #57d757;
+  color: #ffffff;
+}
+</style>

+ 214 - 0
src/components/ls-dom-video/ls-dom-video.vue

@@ -0,0 +1,214 @@
+<!-- eslint-disable -->
+<template>
+  <view
+    v-html="videoHtml"
+    id="dom-video"
+    class="dom-video"
+    :eventDrive="eventDrive"
+    :change:eventDrive="domVideo.eventHandle"
+    :videoSrc="videoSrc"
+    :change:videoSrc="domVideo.srcChange"
+    :videoProps="videoProps"
+    :change:videoProps="domVideo.propsChange"
+    :randomNum="randomNum"
+    :change:randomNum="domVideo.randomNumChange"
+  />
+</template>
+
+<script>
+export default {
+  props: {
+    src: {
+      type: String,
+      default: ''
+    },
+    autoplay: {
+      type: Boolean,
+      default: false
+    },
+    loop: {
+      type: Boolean,
+      default: false
+    },
+    controls: {
+      type: Boolean,
+      default: false
+    },
+    objectFit: {
+      type: String,
+      default: 'contain'
+    },
+    muted: {
+      type: Boolean,
+      default: false
+    },
+    poster: {
+      type: String,
+      default: ''
+    },
+  },
+
+  // 数据状态
+  data() {
+    return {
+      videoHtml: '',
+      videoSrc: '',
+      eventDrive: null,
+      videoProps: {},
+      randomNum: Math.floor(Math.random() * 100000000 + 1)
+    }
+  },
+  watch: {
+    // 监听视频资源文件更新
+    src: {
+      handler(val) {
+        if (!val) return
+        this.initVideoHtml()
+        setTimeout(() => {
+          this.videoSrc = val
+        }, 0)
+      },
+      immediate: true
+    },
+    // 监听首次加载
+    autoplay: {
+      handler(val) {
+        this.videoProps.autoplay = val
+      },
+      immediate: true
+    },
+  },
+  // 生命周期
+  mounted() {
+    this.initVideoHtml()
+  },
+
+  // 方法
+  methods: {
+    // 将video的事件传递给父组件
+    videoEvent(data) {
+      // console.log('向父组件传递事件 =>', data)
+      this.$emit(data)
+    },
+    // 初始化视频
+    initVideoHtml() {
+      this.videoHtml = `<video
+          src="${this.src}"
+          id="dom-html-video_${this.randomNum}"
+          class="dom-html-video"
+          ${this.autoplay ? 'autoplay' : ''}
+          ${this.loop ? 'loop' : ''}
+          ${this.controls ? 'controls' : ''}
+          ${this.muted ? 'muted' : ''}
+          ${this.poster ? 'poster="' + this.poster + '"' : ''}
+          preload="auto"
+          playsinline
+          webkit-playsinline
+          width="100%"
+          height="100%"
+          style="object-fit: ${this.objectFit};padding:0;"
+        >
+          <source src="${this.src}" type="video/mp4">
+          <source src="${this.src}" type="video/ogg">
+          <source src="${this.src}" type="video/webm">
+        </video>
+      `
+      // console.log('视频html =>', this.videoHtml)
+    },
+    resetEventDrive() {
+      this.eventDrive = null
+    },
+    // 将service层的事件/数据 => 传递给renderjs层
+    play() {
+      this.eventDrive = 'play'
+    },
+    pause() {
+      this.eventDrive = 'pause'
+    },
+    stop() {
+      this.eventDrive = 'stop'
+    }
+  }
+}
+</script>
+
+<script module="domVideo" lang="renderjs">
+export default {
+  data() {
+    return {
+      video: null,
+      num: '',
+      options: {}
+    }
+  },
+  mounted() {
+    this.initVideoEvent()
+  },
+  methods: {
+    initVideoEvent() {
+      setTimeout(() => {
+        let video = document.getElementById(`dom-html-video_${this.num}`)
+        this.video = video
+
+        // 监听视频事件
+        video.addEventListener('play', () => {
+          this.$ownerInstance.callMethod('videoEvent', 'play')
+        })
+        video.addEventListener('pause', () => {
+          this.$ownerInstance.callMethod('videoEvent', 'pause')
+        })
+        video.addEventListener('ended', () => {
+          this.$ownerInstance.callMethod('videoEvent', 'ended')
+          this.$ownerInstance.callMethod('resetEventDrive')
+        })
+      }, 100)
+    },
+    eventHandle(eventType) {
+      if (eventType) {
+        this.video = document.getElementById(`dom-html-video_${this.num}`)
+
+        if (eventType === 'play') {
+          this.video.play()
+        } else if (eventType === 'pause') {
+          this.video.pause()
+        } else if (eventType === 'stop') {
+          this.video.stop()
+        }
+      }
+    },
+    srcChange(val) {
+      // 实现视频的第一帧作为封面,避免视频展示黑屏
+      this.initVideoEvent()
+      setTimeout(() => {
+        let video = document.getElementById(`dom-html-video_${this.num}`)
+
+        video.addEventListener('loadedmetadata', () => {
+          let { autoplay } = this.options
+          video.play()
+          if (!autoplay) {
+            video.pause()
+          }
+        })
+      }, 0)
+    },
+    propsChange(obj) {
+      this.options = obj
+    },
+    randomNumChange(val) {
+      this.num = val
+    },
+  }
+}
+</script>
+
+
+<style lang="scss" scoped>
+.dom-video {
+  overflow: hidden;
+  height: 100%;
+  padding: 0;
+  &-height {
+    height: 100%;
+  }
+}
+</style>

+ 14 - 0
src/components/messages/MessageEvent.vue

@@ -0,0 +1,14 @@
+<template>
+  <view class="cu-info round">{{message.content}}</view>
+</template>
+
+<script setup lang="ts">
+import type Message from "@/mode/Message";
+
+interface Props<T> {
+  message: T
+}
+defineProps<Props<Message>>()
+</script>
+
+<style scoped></style>

+ 61 - 0
src/components/messages/MessageFile.vue

@@ -0,0 +1,61 @@
+<template>
+  <view class="file-box" v-if="message.messageType===MessageType.file && message.extend?.url && message.extend?.fileName">
+    <view class="file-icon">
+      <text class="cuIcon cuIcon-down" style="color: #666666;"></text>
+    </view>
+    <uni-link color="#666666" class="file-text" :href="message.extend.url" :text="message.extend.fileName"/>
+  </view>
+</template>
+
+<script setup lang="ts">
+import MessageType from "@/utils/MessageType";
+import UniLink from "@/uni_modules/uni-link/components/uni-link/uni-link.vue";
+import type Message from "@/mode/Message";
+
+interface Props<T> {
+  message: T
+}
+defineProps<Props<Message>>()
+</script>
+
+<style lang="scss" scoped>
+.file-box {
+  width: 60vw;
+  display: flex;
+  background-color: #efefef;
+  color: #666666;
+
+  .file-icon {
+    background-color: #cccccc;
+    padding: 10px;
+    width: 60px;
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .cuIcon {
+      line-height: normal;
+      font-size: 2rem;
+    }
+  }
+
+  .file-text {
+    width: 0;
+    padding: 10px;
+    flex: 5;
+    display: flex;
+    align-items: center;
+    flex-shrink: 0;
+    overflow: hidden;
+    text-decoration: none;
+    .file-name {
+      -webkit-line-clamp: 2;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      overflow-wrap: break-word;
+      word-break: break-all;
+    }
+  }
+}
+</style>

+ 30 - 0
src/components/messages/MessageImage.vue

@@ -0,0 +1,30 @@
+<template>
+  <view v-if="message.messageType===MessageType.image && message.extend" >
+    <image style="max-width: 450upx;" :src="message.extend.url"  mode="heightFix" @tap="preImg(message.extend.url)"/>
+  </view>
+  <q-preview-image ref="previewImage" :urls="[message.extend?.url]"></q-preview-image>
+</template>
+
+<script setup lang="ts">
+import MessageType from "@/utils/MessageType";
+import type Message from "@/mode/Message";
+import QPreviewImage from "@/uni_modules/q-previewImage/components/q-previewImage/q-previewImage.vue";
+import {ref} from "vue";
+const previewImage = ref(null);
+interface Props<T> {
+  message: T
+}
+defineProps<Props<Message>>()
+
+const preImg = (url:string) => {
+  previewImage.value?.open(url)
+}
+
+</script>
+
+<style scoped>
+.ulink{
+  display: block;
+  color: #1cbbb4;
+}
+</style>

+ 84 - 0
src/components/messages/MessageMultipleForward.vue

@@ -0,0 +1,84 @@
+<template>
+  <view
+      class="im-chat-text"
+      @tap="showMessageList"
+  >
+    <view class="title">{{ message.content }}的聊天记录</view>
+    <view v-for="item in message.extend!.messageList!.slice(0, 2)" :key="item.id" class="message">
+      <text class="text" v-if="item.messageType === MessageType.text">{{ userStore.getMapUser(item.fromId)?.name }}:{{ item.content }}</text>
+      <text class="text" v-if="item.messageType === MessageType.image">{{ userStore.getMapUser(item.fromId)?.name }}:[图片]</text>
+      <text class="text" v-if="item.messageType === MessageType.file">{{ userStore.getMapUser(item.fromId)?.name }}:[文件]</text>
+      <text class="text" v-if="item.messageType === MessageType.video">{{ userStore.getMapUser(item.fromId)?.name }}:[视频]</text>
+      <text class="text" v-if="item.messageType === MessageType.voice">{{ userStore.getMapUser(item.fromId)?.name }}:[语音]</text>
+      <text class="text" v-if="item.messageType === MessageType.forward">{{ userStore.getMapUser(item.fromId)?.name }}:[转发]</text>
+      <text class="text" v-if="item.messageType === MessageType.event">{{ userStore.getMapUser(item.fromId)?.name }}:[提示]</text>
+    </view>
+    <div class="footer">聊天记录</div>
+  </view>
+</template>
+<script setup lang="ts">
+import type Message from '@/mode/Message'
+import {useUserStore} from '@/store/userStore'
+import MessageType from '@/utils/MessageType'
+import {useMessageStore} from "@/store/messageStore";
+
+interface Props<T> {
+  message: T
+}
+
+const props = defineProps<Props<Message>>()
+const userStore = useUserStore()
+
+const showMessageList = () => {
+  if(props.message.extend!.messageList){
+    useMessageStore().setMessage(props.message)
+    uni.navigateTo({
+      url:'/pages/chat/forward'
+    })
+  }
+}
+</script>
+<style scoped lang="scss">
+.im-chat-text {
+  cursor: pointer;
+  width: 400upx;
+  background-color: #ffffff !important;
+  padding-bottom: 5px;
+
+  .title {
+    font-weight: bold;
+    font-size: 22upx;
+    line-height: 2;
+    color: #333333;
+  }
+
+  .text {
+    display: inline-block;
+    color: #cccccc;
+    width: 100%;
+    font-size: 22upx;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+    word-break: break-all;
+  }
+}
+
+.im-chat-main .messages .im-chat-text:after {
+  border-style: solid dashed dashed;
+  border-color: #ffffff transparent transparent;
+}
+
+.im-chat-main .messages .im-chat-mine .im-chat-text:after {
+  border-style: solid dashed dashed;
+  border-color: #ffffff transparent transparent;
+}
+
+.footer {
+  color: #999999;
+  font-size: 20upx;
+  border-top: 1px solid #f0f0f0;
+  margin-top: 5px;
+  line-height: 200%;
+}
+</style>

+ 19 - 0
src/components/messages/MessageText.vue

@@ -0,0 +1,19 @@
+<template>
+  <uv-parse v-if="!message.messageType||message.messageType===MessageType.text" :content="ChatUtils.transformXss(message.content,message)"></uv-parse>
+</template>
+
+<script setup lang="ts">
+import MessageType from "@/utils/MessageType";
+import ChatUtils from "@/utils/ChatUtils";
+import type Message from "@/mode/Message";
+import UvParse from "@/uni_modules/uv-parse/components/uv-parse/uv-parse.vue";
+
+interface Props<T> {
+  message: T
+}
+
+defineProps<Props<Message>>()
+
+</script>
+
+<style scoped></style>

+ 24 - 0
src/components/messages/MessageVideo.vue

@@ -0,0 +1,24 @@
+<template>
+  <view v-if="message.messageType===MessageType.video && message.extend && message.extend.url" class="main">
+    <!-- #ifndef APP-PLUS -->
+    <video :src="message.extend.url" controls style="width: 50vw"></video>
+    <!-- #endif -->
+    <!-- #ifdef APP-PLUS -->
+    <dom-video :src="message.extend.url" :controls='true' objectFit="contain"/>
+    <!-- #endif -->
+  </view>
+</template>
+
+<script setup lang="ts">
+import type Message from "@/mode/Message";
+import MessageType from "@/utils/MessageType";
+// #ifdef APP-PLUS
+import domVideo from "@/components/ls-dom-video/ls-dom-video.vue";
+// #endif
+interface Props<T> {
+  message: T
+}
+defineProps<Props<Message>>()
+</script>
+
+<style scoped></style>

+ 84 - 0
src/components/messages/MessageVoice.vue

@@ -0,0 +1,84 @@
+<template>
+  <view v-if="message.messageType===MessageType.voice" class="main">
+    <view @tap="handleAudio(message)">
+      <text>{{ message.extend.time }}"</text>
+      <view class="sound" :class="showSpinner?'active':''">
+        <text class="cuIcon-sound text-xxl"></text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import {ref} from 'vue'
+import MessageType from "@/utils/MessageType";
+import type Message from "@/mode/Message";
+
+const Audio = ref(uni.createInnerAudioContext());
+const showSpinner = ref(false);
+
+interface Props<T> {
+  message: T
+}
+
+defineProps<Props<Message>>()
+//控制播放还是暂停音频文件
+const handleAudio = (item: Message) => {
+  Audio.value.paused ? playAudio(item) : stopAudio();
+}
+//播放音频
+const playAudio = (item: Message) => {
+  if (item.extend?.url) {
+    showSpinner.value = true;
+    Audio.value.src = item.extend?.url
+    Audio.value.play();
+    Audio.value.onEnded(() => {
+      showSpinner.value = false;
+    })
+
+    Audio.value.onStop(() => {
+      showSpinner.value = false;
+    })
+  }
+}
+//停止音频
+const stopAudio = () => {
+  Audio.value.src = '';
+  Audio.value.stop();
+}
+</script>
+
+<style scoped>
+/* 创建名为rotate的关键帧 */
+@keyframes rotate {
+  0% {
+    transform: scale(1, 1);
+  }
+  50% {
+    transform: scale(1.2, 1.2);
+  }
+  100% {
+    transform: scale(1, 1);
+  }
+}
+
+.sound {
+  display: inline-flex;
+  width: 1.25rem;
+  height: 1.25rem;
+
+  transform-origin: center;
+  justify-content: center;
+  align-items: center;
+}
+
+.active {
+  animation-name: rotate;
+  animation-duration: 0.5s;
+  animation-iteration-count: infinite;
+  animation-timing-function: linear;
+  color: #1cbbb4;
+}
+
+
+</style>

+ 153 - 0
src/components/mix-tree/mix-tree.vue

@@ -0,0 +1,153 @@
+<template>
+	<view class="content">
+		<view class="mix-tree-list">
+			<block v-for="(item, index) in treeList" :key="index">
+				<view 
+					class="mix-tree-item solid-bottom flex"
+					:style="[{
+							paddingLeft: item.rank*15 + 'px',
+							zIndex: item.rank*-1 +50
+						}]"
+					:class="{
+							border: treeParams.border === true,
+							show: item.show,
+							last: item.lastRank,
+							showchild: item.showChild
+						}"
+					@click.stop="treeItemTap(item, index)"
+				>
+					<view class="flex-treble">
+						<image class="mix-tree-icon" :src="item.lastRank ? treeParams.lastIcon : item.showChild ? treeParams.currentIcon : treeParams.defaultIcon"></image>
+						{{item.name}}
+					</view>
+					<view class="flex-sub text-right padding-right text-gray">
+						<text></text>
+					</view>
+				</view>
+			</block>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		props: {
+			list: {
+				type: Array,
+				default(){
+					return [];
+				}
+			},
+			params: {
+				type: Object,
+				default(){
+					return {}
+				}
+			}
+		},
+		data() {
+			return {
+				treeList: [],
+				treeParams: {
+					defaultIcon: '/static/mix-tree/defaultIcon.png',
+					currentIcon: '/static/mix-tree/currentIcon.png',
+					lastIcon: '',
+					border: false
+				}
+			}
+		},
+		watch: {
+			list(list){
+				this.treeParams = Object.assign(this.treeParams, this.params);
+				this.renderTreeList(list);
+			}
+		},
+		methods: {
+			//扁平化树结构
+			renderTreeList(list=[], rank=0, parentId=[]){
+				list.forEach(item=>{
+					this.treeList.push({
+						id: item.id,
+						name: item.label,
+						count: item.count,
+						parentId,  // 父级id数组
+						rank,  // 层级
+						showChild: true,  //子级是否显示
+						show: true  // 自身是否显示
+					})
+					if(Array.isArray(item.children) && item.children.length > 0){
+						let parents = [...parentId];
+						parents.push(item.id);
+						this.renderTreeList(item.children, rank+1, parents);
+					}else{
+						this.treeList[this.treeList.length-1].lastRank = true;
+					}
+				})
+			},
+			// 点击
+			treeItemTap(item){
+				let list = this.treeList;
+				let id = item.id;
+				if(item.lastRank === true){
+					//点击最后一级时触发事件
+					this.$emit('treeItemClick', item);
+					return;
+				}
+				item.showChild = !item.showChild;
+				list.forEach(childItem=>{
+					if(item.showChild === false){
+						//隐藏所有子级
+						if(!childItem.parentId.includes(id)){
+							return;
+						}
+						if(childItem.lastRank !== true){
+							childItem.showChild = false;
+						}
+						childItem.show = false;
+					}else{
+						if(childItem.parentId[childItem.parentId.length-1] === id){
+							childItem.show = true;
+						}
+					}
+				})
+			}
+		}
+	}
+</script>
+
+<style scoped>
+	.mix-tree-list{
+		display: flex;
+		flex-direction: column;
+	}
+	.mix-tree-item{
+		display: flex;
+		align-items: center;
+		font-size: 30upx;
+		color: #333;
+		height: 0;
+		opacity: 0;
+		transition: .2s;
+		position: relative;
+	}
+	.mix-tree-item.border{
+		border-bottom: 1px solid #eee;
+	}
+	.mix-tree-item.show{
+		height: 100upx;
+		opacity: 1;
+	}
+	.mix-tree-icon{
+		width: 26upx;
+		height: 26upx;
+		margin-right: 8upx;
+		opacity: .9;
+	}
+	
+	.mix-tree-item.showchild:before{
+		transform: rotate(90deg);
+	}
+	.mix-tree-item.last:before{
+		opacity: 0;
+	}
+</style>

+ 372 - 0
src/components/uni-fab/uni-fab.vue

@@ -0,0 +1,372 @@
+ 
+<template>
+    <view>
+        <view
+            class="fab-box fab" 
+            :class="{
+                leftBottom: leftBottom,
+                rightBottom: rightBottom,
+                leftTop: leftTop,
+                rightTop: rightTop
+            }"
+        >
+            <view
+                class="fab-circle"
+                :class="{
+                    left: horizontal === 'left' && direction === 'horizontal',
+                    top: vertical === 'top' && direction === 'vertical',
+                    bottom: vertical === 'bottom' && direction === 'vertical',
+                    right: horizontal === 'right' && direction === 'horizontal'
+                }"
+                :style="{ 'background-color': styles.buttonColor }"
+                @click="open"
+            >
+                <text class="icon icon-jia" :class="{ active: showContent }"></text>
+            </view>
+            <view
+                class="fab-content"
+                :class="{
+                    left: horizontal === 'left',
+                    right: horizontal === 'right',
+                    flexDirection: direction === 'vertical',
+                    flexDirectionStart: flexDirectionStart,
+                    flexDirectionEnd: flexDirectionEnd
+                }"
+                :style="{ width: boxWidth, height: boxHeight, background: styles.backgroundColor }"
+            >
+                <view v-if="flexDirectionStart || horizontalLeft" class="fab-item first"></view>
+                <view
+                    class="fab-item"
+                    v-for="(item, index) in content"
+                    :key="index"
+                    :class="{ active: showContent }"
+                    :style="{
+                        color: item.active ? styles.selectedColor : styles.color
+                    }"
+                    @click="taps(index, item)"
+                >
+                    <image
+                        class="content-image"
+                        :src="item.active ? item.selectedIconPath : item.iconPath"
+                        mode=""
+                    ></image>
+                    <text class="text">{{ item.text }}</text>
+                </view>
+                <view v-if="flexDirectionEnd || horizontalRight" class="fab-item first"></view>
+            </view>
+        </view>
+    </view>
+</template>
+ 
+<script>
+export default {
+    props: {
+        pattern: {
+            type: Object,
+            default: () => {
+                return {};
+            }
+        },
+        horizontal: {
+            type: String,
+            default: 'left'
+        },
+        vertical: {
+            type: String,
+            default: 'bottom'
+        },
+        direction: {
+            type: String,
+            default: 'horizontal'
+        },
+        content: {
+            type: Array,
+            default: () => {
+                return [];
+            }
+        }
+    },
+    data() {
+        return {
+            fabShow: false,
+            flug: true,
+            showContent: false,
+            styles: {
+                color: '#3c3e49',
+                selectedColor: '#007AFF',
+                backgroundColor: '#fff',
+                buttonColor: '#3c3e49'
+            }
+        };
+    },
+    created() {
+        if (this.top === 0) {
+            this.fabShow = true;
+        }
+        // 初始化样式
+        this.styles = Object.assign({}, this.styles, this.pattern);
+    },
+    methods: {
+        open() {
+            this.showContent = !this.showContent;
+        },
+        /**
+         * 按钮点击事件
+         */
+        taps(index, item) {
+            this.$emit('trigger', {
+                index,
+                item
+            });
+            
+            this.showContent = false;
+        },
+        /**
+         * 获取 位置信息
+         */
+        getPosition(types, paramA, paramB) {
+            if (types === 0) {
+                return this.horizontal === paramA && this.vertical === paramB;
+            } else if (types === 1) {
+                return this.direction === paramA && this.vertical === paramB;
+            } else if (types === 2) {
+                return this.direction === paramA && this.horizontal === paramB;
+            } else {
+                return this.showContent && this.direction === paramA
+                    ? this.contentWidth
+                    : this.contentWidthMin;
+            }
+        }
+    },
+    watch: {
+        pattern(newValue, oldValue) {
+            console.log(JSON.stringify(newValue));
+            this.styles = Object.assign({}, this.styles, newValue);
+        }
+    },
+    computed: {
+        contentWidth(e) {
+            return uni.upx2px((this.content.length + 1) * 110 + 20) + 'px';
+        },
+        contentWidthMin() {
+            return uni.upx2px(110) + 'px';
+        },
+        // 动态计算宽度
+        boxWidth() {
+            return this.getPosition(3, 'horizontal');
+        },
+        // 动态计算高度
+        boxHeight() {
+            return this.getPosition(3, 'vertical');
+        },
+        // 计算左下位置
+        leftBottom() {
+            return this.getPosition(0, 'left', 'bottom');
+        },
+        // 计算右下位置
+        rightBottom() {
+            return this.getPosition(0, 'right', 'bottom');
+        },
+        // 计算左上位置
+        leftTop() {
+            return this.getPosition(0, 'left', 'top');
+        },
+        rightTop() {
+            return this.getPosition(0, 'right', 'top');
+        },
+        flexDirectionStart() {
+            return this.getPosition(1, 'vertical', 'top');
+        },
+        flexDirectionEnd() {
+            return this.getPosition(1, 'vertical', 'bottom');
+        },
+        horizontalLeft() {
+            return this.getPosition(2, 'horizontal', 'left');
+        },
+        horizontalRight() {
+            return this.getPosition(2, 'horizontal', 'right');
+        }
+    }
+};
+</script>
+ 
+<style scoped>
+.fab-box {
+    position: fixed;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    z-index: 2;
+}
+ 
+.fab-box.top {
+    width: 60upx;
+    height: 60upx;
+    right: 30upx;
+    bottom: 60upx;
+    border: 1px #5989b9 solid;
+    background: #6699cc;
+    border-radius: 10upx;
+    color: #fff;
+    transition: all 0.3;
+    opacity: 0;
+}
+ 
+.fab-box.active {
+    opacity: 1;
+}
+ 
+.fab-box.fab {
+    z-index: 10;
+}
+ 
+.fab-box.fab.leftBottom {
+    left: 30upx;
+    bottom: 60upx;
+}
+ 
+.fab-box.fab.leftTop {
+    left: 30upx;
+    top: 80upx;
+    /* #ifdef H5 */
+    top: calc(80upx + var(--window-top));
+    /* #endif */
+}
+ 
+.fab-box.fab.rightBottom {
+    right: 30upx;
+    bottom: 60upx;
+}
+ 
+.fab-box.fab.rightTop {
+    right: 30upx;
+    top: 80upx;
+    /* #ifdef H5 */
+    top: calc(80upx + var(--window-top));
+    /* #endif */
+}
+ 
+.fab-circle {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    position: absolute;
+    width: 110upx;
+    height: 110upx;
+    background: #3c3e49;
+    /* background: #5989b9; */
+    border-radius: 50%;
+    box-shadow: 0 0 5px 2px rgba(0, 0, 0, 0.2);
+    z-index: 11;
+}
+ 
+.fab-circle.left {
+    left: 0;
+}
+ 
+.fab-circle.right {
+    right: 0;
+}
+ 
+.fab-circle.top {
+    top: 0;
+}
+ 
+.fab-circle.bottom {
+    bottom: 0;
+}
+ 
+.fab-circle .icon-jia {
+    color: #ffffff;
+    font-size: 50upx;
+    transition: all 0.3s;
+}
+ 
+.fab-circle .icon-jia.active {
+    transform: rotate(135deg);
+}
+ 
+.fab-content {
+    background: #6699cc;
+    box-sizing: border-box;
+    display: flex;
+    border-radius: 100upx;
+    overflow: hidden;
+    box-shadow: 0 0 5px 2px rgba(0, 0, 0, 0.1);
+    transition: all 0.2s;
+    width: 110upx;
+}
+ 
+.fab-content.left {
+    justify-content: flex-start;
+}
+ 
+.fab-content.right {
+    justify-content: flex-end;
+}
+ 
+.fab-content.flexDirection {
+    flex-direction: column;
+    justify-content: flex-end;
+}
+ 
+.fab-content.flexDirectionStart {
+    flex-direction: column;
+    justify-content: flex-start;
+}
+ 
+.fab-content.flexDirectionEnd {
+    flex-direction: column;
+    justify-content: flex-end;
+}
+ 
+.fab-content .fab-item {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    width: 110upx;
+    height: 110upx;
+    font-size: 24upx;
+    color: #fff;
+    opacity: 0;
+    transition: opacity 0.2s;
+}
+ 
+.fab-content .fab-item.active {
+    opacity: 1;
+}
+ 
+.fab-content .fab-item .content-image {
+    width: 50upx;
+    height: 50upx;
+    margin-bottom: 5upx;
+}
+ 
+.fab-content .fab-item.first {
+    width: 110upx;
+}
+ 
+@font-face {
+    font-family: 'iconfont';
+    src: url('https://at.alicdn.com/t/font_1028200_xhbo4rn58rp.ttf?t=1548214263520')
+        format('truetype');
+}
+ 
+.icon {
+    font-family: 'iconfont' !important;
+    font-size: 16px;
+    font-style: normal;
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
+}
+ 
+.icon-jia:before {
+    content: '\e630';
+}
+ 
+.icon-arrow-up:before {
+    content: '\e603';
+}
+</style>
+ 

+ 31 - 0
src/config/VimConfig.ts

@@ -0,0 +1,31 @@
+interface VimConfigInf {
+  host: string;
+  httProtocol: string;
+  wsProtocol: string;
+  // httPort: number;
+  wsPort: number;
+  client: string;
+  soundPath: string;
+  facesPath: string;
+  openUniPush: boolean;
+  uploadType: string
+}
+
+const VimConfig: VimConfigInf = {
+  host: "newim.taohaowan.com/prod-api",
+  httProtocol: "https",
+  wsProtocol: "wss",
+  // httPort: 8080,
+  wsPort: 443,
+  // 客户端类型
+  client: 'mobile',
+  // 音频路径
+  soundPath: '/static/Message.mp3',
+  // 表情路径
+  facesPath: '/static/faces/',
+  //是否开启uniPush
+  openUniPush: true,
+  // 上传类型  common | minio 如果希望使用minio存储文件,请将uploadType设置为minio, common 为本地存储 (测试服务器不支持minio)
+  uploadType: 'common'
+};
+export default VimConfig;

+ 8 - 0
src/env.d.ts

@@ -0,0 +1,8 @@
+/// <reference types="vite/client" />
+
+declare module '*.vue' {
+    import {DefineComponent} from 'vue'
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
+  const component: DefineComponent<{}, {}, any>
+  export default component
+}

+ 91 - 0
src/hooks/useChatInit.ts

@@ -0,0 +1,91 @@
+import ChatType from '@/utils/ChatType'
+import GroupApi from '@/api/GroupApi'
+import type User from '@/mode/User'
+import UserApi from '@/api/UserApi'
+import {ref} from 'vue'
+import {useChatStore} from '@/store/chatStore'
+import {useUserStore} from '@/store/userStore'
+import MessageApi from "@/api/MessageApi";
+import type Message from "@/mode/Message";
+import {imageLoad} from "@/hooks/useMessageImageLoad";
+import type Chat from "@/mode/Chat";
+import ChatApi from "@/api/ChatApi";
+
+//用户
+const users = ref<Array<User>>()
+//群人数
+const count = ref(0)
+
+const isValidatedUser = ref(true)
+//是否是管理员
+const isMaster = ref(false)
+//是否是有效用户
+
+function loadMessages(chatId: string, user: User, chatType: string, chat: Chat) {
+  MessageApi.list(chatId, user.id, chatType, 100).then((res) => {
+    //读取消息
+    res.data.forEach((item: Message) => {
+      useChatStore().addToMessageList(item, chat,false)
+    })
+    const messageList = useChatStore().chatMessage.get(chatId)
+    if(messageList){
+      imageLoad(messageList)
+    }
+  })
+}
+
+const loadUserOrGroup = (chat: Chat, user: User) => {
+  const chatId = chat.id;
+  const chatType = chat.type;
+  useUserStore().storeUser(user.id, {
+    name: user.name,
+    avatar: user.avatar
+  })
+  if (chatId && chatType === ChatType.GROUP) {
+    GroupApi.users(chatId).then((res) => {
+      const chatList: Array<Chat> = []
+      res.data.forEach((item: User) => {
+        useUserStore().storeUser(item.id, {
+          name: item.name,
+          avatar: item.avatar
+        })
+        chatList.push({
+          type: ChatType.FRIEND,
+          unreadCount: 0,
+          id: item.id,
+          name: item.name,
+          avatar: item.avatar
+        })
+        useChatStore().updateChat(item.id, item.name, item.avatar,false,false)
+      })
+      ChatApi.batch(chatList)
+      users.value = res.data
+      if(users.value){
+        isValidatedUser.value = users.value.some((item) => item.id === user?.id)
+        if(isValidatedUser.value){
+          //第一次加载,从数据库中取100条,有序插入到聊天记录里
+          loadMessages(chatId, user, chatType, chat);
+        }
+      }
+      count.value = res.data.length
+    })
+    GroupApi.get(chatId).then((res) => {
+      const group = res.data
+      isMaster.value = group.master === user?.id
+      useChatStore().updateChat(chatId, group.name, group.avatar)
+    })
+  } else {
+    isValidatedUser.value = true
+    UserApi.getUser(chatId).then((res) => {
+      const chatUser = res.data
+      useChatStore().updateChat(chatUser.id, chatUser.name, chatUser.avatar)
+      useUserStore().storeUser(chatUser.id, {
+        name: chatUser.name,
+        avatar: chatUser.avatar
+      })
+      loadMessages(chatId, user, chatType, chat);
+    })
+  }
+}
+
+export { loadUserOrGroup, users, count, isMaster, isValidatedUser }

+ 29 - 0
src/hooks/useMessageComponent.ts

@@ -0,0 +1,29 @@
+import MessageType from '@/utils/MessageType'
+import MessageText from '@/components/messages/MessageText.vue'
+import MessageImage from '@/components/messages/MessageImage.vue'
+import MessageFile from '@/components/messages/MessageFile.vue'
+import MessageVoice from '@/components/messages/MessageVoice.vue'
+import MessageVideo from '@/components/messages/MessageVideo.vue'
+import MessageMultipleForward from "@/components/messages/MessageMultipleForward.vue";
+import VimPlugin from "@/plugins/VimPlugin";
+
+const useMessageComponent = (type: string) => {
+  switch (type) {
+    case MessageType.image:
+      return MessageImage
+    case MessageType.file:
+      return MessageFile
+    case MessageType.voice:
+      return MessageVoice
+    case MessageType.video:
+      return MessageVideo
+    case MessageType.forward:
+      return MessageMultipleForward
+    case MessageType.text:
+      return MessageText
+    default:
+      return VimPlugin.renderMessage(type)
+  }
+}
+
+export default useMessageComponent

+ 70 - 0
src/hooks/useMessageImageLoad.ts

@@ -0,0 +1,70 @@
+import {nextTick, ref} from "vue"
+import type Message from "@/mode/Message";
+import MessageType from "@/utils/MessageType";
+import MessageApi from "@/api/MessageApi";
+import type User from "@/mode/User";
+import type Chat from "@/mode/Chat";
+import {useChatStore} from "@/store/chatStore";
+
+const last = ref('m-0');
+//每页显示多少条数据
+const pageSize = ref(100)
+//页码
+const pageNum = ref(1)
+/**
+ * 加载图片,确保加载完成,消息滚动到最后。
+ */
+const preloadImages = (arr: any) => {
+    let loadedImage = 0
+    return new Promise((resolve, reject) => {
+        if (arr.length > 0) {
+            for (let i = 0; i < arr.length; i++) {
+                uni.getImageInfo({
+                    src: arr[i],
+                    success() {
+                        loadedImage++
+                        if (loadedImage === arr.length) {
+                            resolve(0)
+                        }
+                    },
+                    fail() {
+                        reject()
+                    }
+                })
+            }
+        } else {
+            resolve(0)
+        }
+    })
+}
+
+/**
+ * 加载图片,确保加载完成,消息滚动到最后。
+ */
+const imageLoad = (messageList:Array<Message>) => {
+    let srcArr: string[] = []
+    if (messageList) {
+        messageList.forEach((item) => {
+            if (item.messageType === MessageType.image && item.extend && item.extend.url) {
+                srcArr.push(item.extend.url)
+            }
+        })
+    }
+    preloadImages(srcArr).finally(()=>{
+        toBottom()
+    })
+}
+
+/**
+ * 聊天记录滚动到最下面
+ */
+const toBottom = () => {
+    last.value = ''
+    nextTick().then(r =>{
+        last.value = 'm-last'
+    })
+}
+
+
+
+export  {last, imageLoad,toBottom}

+ 26 - 0
src/hooks/useMoveMenu.ts

@@ -0,0 +1,26 @@
+import {ref} from "vue";
+
+const listTouchStart = ref(0);
+const listTouchDirection = ref<string | null>('');
+const modalName = ref<string | null>('');
+// ListTouch触摸开始
+const handleListTouchStart = (e: any) => {
+    listTouchStart.value = e.touches[0].pageX
+}
+
+// ListTouch计算方向
+const handleListTouchMove = (e: any) => {
+    listTouchDirection.value = e.touches[0].pageX - listTouchStart.value > -80 ? 'right' : 'left'
+}
+
+// ListTouch计算滚动
+const handleListTouchEnd = (e: any) => {
+    if (listTouchDirection.value == 'left') {
+        modalName.value = e.currentTarget.dataset.target
+    } else {
+        modalName.value = null
+    }
+    listTouchDirection.value = null
+}
+
+export {modalName, handleListTouchStart, handleListTouchMove, handleListTouchEnd}

+ 176 - 0
src/hybrid/html/answer.html

@@ -0,0 +1,176 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>视频通话</title>
+    <link rel="stylesheet" href="./css/video-call.css" />
+</head>
+<body>
+
+<div id="app">
+    <video ref="mainVideo" poster="./images/mt/pt.png" id="vd_user" autoplay class="main-video"></video>
+    <div v-if="!friendData.video" class="friend-info">
+        <img :src="friendAvatar" style="width: 60px;height:60px;border-radius: 5px;margin-top: 100px;" alt=""/>
+        <div class="friend-name">{{ friendName }}</div>
+        <div class="friend-tips">{{isConnect?'正在通话中...':'正在等待接听'}}</div>
+    </div>
+    <video ref="localVideo" :muted="true" poster="./images/mt/pt.png"  autoplay class="local-video"></video>
+    <div class="video-menus">
+        <div>
+            <img @click="closeCalling" src="./images/mt/icon_off.png" class="menu-close" alt=""/>
+        </div>
+        <div v-if="showAccept && !isConnect">
+            <img @click="accept" src="./images/mt/icon_on.png" class="menu-close" alt=""/>
+        </div>
+    </div>
+</div>
+<audio id="bgSound" src="./images/calling.mp3" autoplay loop style='display: none'></audio>
+<script src="./js/vue.global.js"></script>
+<script src="./js/uni.webview.1.5.4.js"></script>
+<script src="./js/peerjs.min.js"></script>
+<script src="./js/video-call.js"></script>
+<script>
+    const {createApp, ref, onMounted} = Vue
+
+    document.addEventListener('UniAppJSBridgeReady', function () {
+        uni.getEnv(function (res) {
+            createApp({
+                setup() {
+                    const id = ref('')
+                    const name = ref('')
+                    const peerId = ref('')
+                    const localPeerId = ref(undefined)
+                    const friendId = ref('')
+                    const friendAvatar = ref('')
+                    const friendName = ref('')
+                    const localData = ref({
+                        audio: false,
+                        video: false
+                    })
+                    const friendData = ref({
+                        audio: false,
+                        video: false
+                    })
+                    const speaker = ref(true)
+                    const showVideo = ref(false)
+                    const isConnect = ref(false)
+                    const showAccept = ref(false)
+                    const mainVideo = ref()
+                    const localVideo = ref()
+                    const localMedia = ref()
+                    const dataConnection = ref()
+
+                    onMounted(() => {
+                        id.value = getQueryVariable("id")
+                        name.value = decodeURI(getQueryVariable("name"))
+                        peerId.value = getQueryVariable("peerId")
+                        friendId.value = getQueryVariable("friendId")
+                        friendName.value = decodeURI(getQueryVariable("friendName"))
+                        friendAvatar.value = getQueryVariable("friendAvatar")
+                        showVideo.value = getQueryVariable("showVideo") === 'true'
+                        const localPeer = initPeer()
+                        localPeer.on('open', (pid) => {
+                            localPeerId.value = pid
+                            dataConnection.value = localPeer.connect(peerId.value)
+
+                            dataConnection.value.on('open', () => {
+                                showAccept.value = true
+                            })
+                            dataConnection.value.on('data', (data) => {
+                                if (data.cmd === PeerCmd.ringOff) {
+                                    closeCalling()
+                                } else if (data.cmd === PeerCmd.busy) {
+                                    closeCalling()
+                                } else if (data.cmd === PeerCmd.accept) {
+                                    getLocalUserMedia({ audio: true, video: showVideo.value })
+                                        .then((userMedia) => {
+                                            localMedia.value = userMedia
+                                            friendData.value.video = showVideo.value
+                                            isConnect.value = true
+                                            const mediaConnection = localPeer.call(peerId.value, userMedia)
+                                            mediaConnection.on('stream', (otherUserMedia) => {
+                                                localData.value.video = showVideo.value
+                                                mainVideo.value.srcObject = otherUserMedia
+                                                mainVideo.value.muted = false
+                                                if (showVideo.value) {
+                                                    localVideo.value.srcObject = userMedia
+                                                    localVideo.value.muted = true
+                                                }
+                                            })
+                                        })
+                                        .catch(() => {
+                                            isConnect.value = false
+                                            closeCalling()
+                                        })
+                                }
+                            })
+                        })
+                    })
+
+                    const muteAudio = (boo) => {
+                        console.log(boo)
+                    }
+                    const changeSpeaker = () => {
+
+                    }
+                    const changeCamera = () => {
+                        console.log('changeCamera')
+                    }
+                    const closeCalling = () => {
+                        //关闭视频流
+                        localMedia.value?.getTracks().forEach((track) => {
+                            track.stop()
+                        })
+                        uni.postMessage({
+                            data: {
+                                type: 'close',
+                                data:{calling:false}
+                            }
+                        })
+                    }
+                    /**
+                     * 接受视频
+                     */
+                    const accept = () => {
+                        uni.postMessage({
+                            data: {
+                                type: 'accept',
+                                data:{
+                                    peerId: localPeerId.value
+                                }
+                            }
+                        })
+                        document.getElementById('bgSound').pause()
+                        //发送请求,告诉对方我要视频
+                        dataConnection.value?.send({
+                            cmd: PeerCmd.request
+                        })
+                    }
+                    return {
+                        id,
+                        name,
+                        friendId,
+                        friendAvatar,
+                        friendName,
+                        localData,
+                        showVideo,
+                        friendData,
+                        isConnect,
+                        showAccept,
+                        changeCamera,
+                        changeSpeaker,
+                        muteAudio,
+                        mainVideo,
+                        localVideo,
+                        closeCalling,
+                        accept,
+                        speaker
+                    }
+                }
+            }).mount('#app')
+        });
+    });
+</script>
+</body>
+</html>

+ 184 - 0
src/hybrid/html/calling.html

@@ -0,0 +1,184 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>视频通话</title>
+    <link rel="stylesheet" href="./css/video-call.css" />
+</head>
+<body>
+
+<div id="app">
+    <video ref="mainVideo" poster="./images/mt/pt.png"  autoplay class="main-video"></video>
+    <div class="friend-info" v-if="!(showVideo && isConnect)">
+        <img :src="friendAvatar" style="width: 60px;height:60px;border-radius: 5px;margin-top: 100px;" alt=""/>
+        <div class="friend-name">{{ friendName }}</div>
+        <div class="friend-tips">{{isConnect?'正在通话中...':'正在等待对方接听'}}</div>
+    </div>
+    <video ref="localVideo" :muted="true" poster="./images/mt/pt.png" autoplay class="local-video"></video>
+    <div class="video-menus">
+        <div>
+            <img @click="closeCalling" src="./images/mt/icon_off.png" class="menu-close"/>
+        </div>
+    </div>
+</div>
+<script src="./js/vue.global.js"></script>
+<script src="./js/uni.webview.1.5.4.js"></script>
+<script src="./js/peerjs.min.js"></script>
+<script src="./js/video-call.js"></script>
+<script>
+    const {createApp, ref, onMounted} = Vue
+
+    document.addEventListener('UniAppJSBridgeReady', function () {
+        uni.getEnv(function () {
+            createApp({
+                setup() {
+                    const id = ref('')
+                    const name = ref('')
+                    const friendId = ref('')
+                    const friendAvatar = ref('')
+                    const friendName = ref('')
+                    const localData = ref({
+                        audio: false,
+                        video: false
+                    })
+                    const friendData = ref({
+                        audio: false,
+                        video: false
+                    })
+                    const speaker = ref(true)
+                    const showVideo = ref(false)
+                    const isConnect = ref(false)
+                    const mainVideo = ref()
+                    const localVideo = ref()
+                    const localMedia = ref()
+                    onMounted(() => {
+                        id.value = getQueryVariable("id")
+                        name.value = decodeURI(getQueryVariable("name"))
+                        friendId.value = getQueryVariable("friendId")
+                        friendName.value = decodeURI(getQueryVariable("friendName"))
+                        friendAvatar.value = getQueryVariable("friendAvatar")
+                        showVideo.value = getQueryVariable("showVideo") === 'true'
+                        getLocalUserMedia({audio: true, video: showVideo.value})
+                            .then((userMedia) => {
+                                localMedia.value = userMedia
+                                const localPeer = initPeer()
+                                if (showVideo.value) {
+                                    localVideo.value.srcObject = userMedia
+                                    localVideo.value.muted = true
+                                }
+
+                                localPeer.on('call', (mediaConnection) => {                                
+									friendData.value.video = true;
+                                    mediaConnection.answer(userMedia)
+                                    mediaConnection.on('stream', (otherUserMedia) => {
+                                        isConnect.value = true
+                                        mainVideo.value.srcObject = otherUserMedia
+                                        mainVideo.value.muted = false
+                                        //告诉VideoCalling.vue 已经接通
+                                        uni.postMessage({
+                                            data: {
+                                                type: 'connect',
+                                                data:{}
+                                            }
+                                        })
+                                    })
+                                })
+                                //这里是接受视频请求,但是有可能是多个人同时请求,所以这里需要判断是否已经在通话中
+                                localPeer.on('connection', (dataConnection) => {
+                                    dataConnection.on('data', (data) => {
+                                        if (data.cmd === PeerCmd.ringOff) {
+                                            closeCalling()
+                                        } else if (data.cmd === PeerCmd.request) {
+                                            if (isConnect.value) {
+                                                dataConnection.send({
+                                                    cmd: PeerCmd.busy
+                                                })
+                                            } else {
+                                                isConnect.value = true
+                                                dataConnection.send({
+                                                    cmd: PeerCmd.accept
+                                                })
+                                            }
+                                        } else if (data.cmd === PeerCmd.reject) {
+                                            closeCalling()
+                                        }
+                                    })
+                                })
+
+                                localPeer.on('error', () => {
+                                    closeCalling()
+                                })
+
+                                //给对方发送自己的peerId
+                                localPeer.on('open', (localPeerId) => {
+                                    uni.postMessage({
+                                        data: {
+                                            type: 'ws',
+                                            data: {
+                                                code: SendVideoCode.VIDEO,
+                                                message: {
+                                                    chatId: friendId.value,
+                                                    fromId: id.value,
+                                                    peerId: localPeerId,
+                                                    showVideo: showVideo.value,
+                                                    timestamp: new Date().getTime(),
+                                                }
+                                            }
+                                        }
+                                    })
+
+                                })
+                            })
+                            .catch((err) => {
+                                isConnect.value = false
+                                closeCalling()
+                            })
+                    })
+
+                    const muteAudio = (boo) => {
+                        console.log(boo)
+                    }
+                    const changeSpeaker = () => {
+
+                    }
+                    const changeCamera = () => {
+                        console.log('changeCamera')
+                    }
+                    const closeCalling = () => {
+                        //关闭视频流
+                        localMedia.value?.getTracks().forEach((track) => {
+                            track.stop()
+                        })
+                        uni.postMessage({
+                            data: {
+                                type: 'close',
+                                data:{calling:true}
+                            }
+                        })
+                    }
+                    return {
+                        id,
+                        name,
+                        friendId,
+                        friendAvatar,
+                        friendName,
+                        localData,
+                        showVideo,
+                        friendData,
+                        isConnect,
+                        changeCamera,
+                        changeSpeaker,
+                        muteAudio,
+                        mainVideo,
+                        localVideo,
+                        closeCalling,
+                        speaker
+                    }
+                }
+            }).mount('#app')
+        });
+    });
+</script>
+</body>
+</html>

+ 105 - 0
src/hybrid/html/css/video-call.css

@@ -0,0 +1,105 @@
+body {
+    padding: 0;
+    margin: 0;
+    height: 100vh;
+    width: 100vw;
+    overflow: hidden;
+    box-sizing: border-box;
+}
+
+.main-video {
+    position: fixed;
+    top: 0;
+    left: 0;
+    z-index: 4;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+
+.friend-info {
+    position: fixed;
+    top: 0;
+    left: 0;
+    z-index: 5;
+    width: 100%;
+    height: 100%;
+    background: transparent;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+}
+
+.friend-info .friend-name {
+    margin-top: 8px;
+    color: white;
+    font-size: 12px;
+}
+
+.friend-info .friend-tips {
+    margin-top: 120px;
+    color: white;
+}
+
+.local-video {
+    position: fixed;
+    top: 20px;
+    right: 25px;
+    z-index: 6;
+    width: 100px;
+    height: 230px;
+}
+
+.video-menus {
+    display: flex;
+    justify-content: space-between;
+    position: fixed;
+    bottom: 25px;
+    left: 0;
+    width: 100%;
+    z-index: 9;
+}
+
+.video-menus > div {
+    display: flex;
+    flex: 1;
+    align-items: center;
+    justify-content: center;
+}
+
+.menu-1 {
+    width: 40px;
+    height: 40px;
+    z-index: 9;
+    margin-left: 20px
+}
+
+.menu-2 {
+    width: 40px;
+    height: 40px;
+    z-index: 9;
+}
+
+.menu-close {
+    width: 60px;
+    height: 60px;
+}
+
+.menu-3 {
+    width: 34px;
+    height: 34px;
+    z-index: 9;
+}
+
+.menu-3 {
+    width: 34px;
+    height: 34px;
+    z-index: 9;
+}
+
+.menu-4 {
+    width: 40px;
+    height: 40px;
+    z-index: 9;
+    margin-right: 20px;
+}

BIN
src/hybrid/html/images/calling.mp3


BIN
src/hybrid/html/images/mt/audioffx.png


BIN
src/hybrid/html/images/mt/audiooff.png


BIN
src/hybrid/html/images/mt/audioon.png


BIN
src/hybrid/html/images/mt/camerachange.png


BIN
src/hybrid/html/images/mt/cameraoff.png


BIN
src/hybrid/html/images/mt/cameraoffx.png


BIN
src/hybrid/html/images/mt/cameraon.png


BIN
src/hybrid/html/images/mt/exitmt.png


BIN
src/hybrid/html/images/mt/icon_off.png


BIN
src/hybrid/html/images/mt/icon_on.png


BIN
src/hybrid/html/images/mt/icon_spk0.png


BIN
src/hybrid/html/images/mt/icon_spk1.png


BIN
src/hybrid/html/images/mt/loading.gif


BIN
src/hybrid/html/images/mt/pt.png


File diff suppressed because it is too large
+ 8 - 0
src/hybrid/html/js/peerjs.min.js


File diff suppressed because it is too large
+ 1 - 0
src/hybrid/html/js/uni.webview.1.5.4.js


+ 83 - 0
src/hybrid/html/js/video-call.js

@@ -0,0 +1,83 @@
+const peerConfig = {
+  peerHost: '101.200.151.183',
+  peerPort: 9000,
+  peerPath: '/',
+  stunServer: 'stun:101.200.151.183:3478',
+  turnServer: 'turn:101.200.151.183:3478',
+  turnUserName: 'aaaaa',
+  turnPassword: 'bbbbb'
+}
+
+const initPeer = () => {
+    return new Peer({
+        //peer server 是用此域名的证书启动的,所以这里用域名
+        host: peerConfig.peerHost,
+        port: peerConfig.peerPort,
+        path: peerConfig.peerPath,
+        secure: false,
+        config: {
+            iceServers: [
+                { urls: peerConfig.stunServer },
+                {
+                    urls: peerConfig.turnServer,
+                    username: peerConfig.turnUserName,
+                    credential: peerConfig.turnPassword
+                }
+            ]
+        }
+    })
+}
+
+const PeerCmd = {
+    /**
+     * 请求视频通话
+     */
+    request: 1,
+    /**
+     * 拒绝视频通话
+     */
+    reject: 2,
+    /**
+     * 接受视频通话
+     */
+    accept: 3,
+    /**
+     * 取消视频通话
+     */
+    cancel: 4,
+    /**
+     * 正常挂断
+     */
+    ringOff: 5,
+    /**
+     * 忙碌挂断
+     */
+    busy: 6
+}
+
+const SendVideoCode = {
+    //视频通话
+    VIDEO: '8',
+    //关闭视频
+    CLOSE_ALL: '9'
+}
+
+
+const getLocalUserMedia = (constrains) => {
+    if (window.navigator.mediaDevices.getUserMedia) {
+        return window.navigator.mediaDevices.getUserMedia(constrains)
+    }
+    throw new Error('unable to get user media')
+}
+
+const getQueryVariable = (variable) => {
+    let query = window.location.search.substring(1);
+    let vars = query.split("&");
+    for (let i = 0; i < vars.length; i++) {
+        let pair = vars[i].split("=");
+        if (pair[0] === variable) {
+            return pair[1];
+        }
+    }
+    return "";
+}

File diff suppressed because it is too large
+ 15929 - 0
src/hybrid/html/js/vue.global.js


+ 39 - 0
src/main.ts

@@ -0,0 +1,39 @@
+import {createSSRApp} from "vue";
+import App from "./App.vue";
+import store from "./store";
+
+export function createApp() {
+  const app = createSSRApp(App);
+  app.use(store)
+  uni.getSystemInfo({
+    success: function(e) {
+      // #ifndef MP
+      app.config.globalProperties.statusBar = e.statusBarHeight;
+      if (e.platform == 'android') {
+        // @ts-ignore
+        app.config.globalProperties.customBar = e.statusBarHeight + 50;
+      } else {
+        // @ts-ignore
+        app.config.globalProperties.customBar = e.statusBarHeight + 45;
+      };
+      // #endif
+
+      // #ifdef MP-WEIXIN
+      app.config.globalProperties.statusBar = e.statusBarHeight;
+      // @ts-ignore
+      let custom = wx.getMenuButtonBoundingClientRect();
+      // @ts-ignore
+      app.config.globalProperties.customBar = custom.bottom + custom.top - e.statusBarHeight;
+      // #endif
+
+      // #ifdef MP-ALIPAY
+      app.config.globalProperties.statusBar = e.statusBarHeight;
+      // @ts-ignore
+      app.config.globalProperties.customBar = e.statusBarHeight + e.titleBarHeight;
+      // #endif
+    }
+  })
+  return {
+    app,
+  };
+}

+ 194 - 0
src/manifest.json

@@ -0,0 +1,194 @@
+{
+    "name" : "thw-IM",
+    "appid" : "__UNI__9249BBF",
+    "description" : "",
+    "versionName" : "2.7.0",
+    "versionCode" : 270,
+    "transformPx" : false,
+    "app-plus" : {
+        "compatible" : {
+            "ignoreVersion" : true
+        },
+        "usingComponents" : true,
+        "nvueStyleCompiler" : "uni-app",
+        "compilerVersion" : 3,
+        "splashscreen" : {
+            "alwaysShowBeforeRender" : true,
+            "waiting" : true,
+            "autoclose" : true,
+            "delay" : 0
+        },
+        "modules" : {
+            "VideoPlayer" : {},
+            "Record" : {},
+            "Push" : {},
+            "Camera" : {},
+            "Barcode" : {},
+            "LivePusher" : {}
+        },
+        "distribute" : {
+            "android" : {
+                "permissions" : [
+                    "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_CONTACTS\"/>",
+                    "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
+                    "<uses-permission android:name=\"android.permission.WRITE_CONTACTS\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CAMERA\"/>",
+                    "<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
+                    "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
+                    "<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
+                    "<uses-permission android:name=\"android.permission.CALL_PHONE\"/>",
+                    "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
+                    "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
+                ],
+                "minSdkVersion" : 21
+            },
+            "ios" : {
+                "dSYMs" : false
+            },
+            "sdkConfigs" : {
+                "ad" : {},
+                "push" : {
+                    "unipush" : {
+                        "version" : "2",
+                        "offline" : false,
+                        "icons" : {
+                            "small" : {
+                                "hdpi" : "C:/Users/z/Desktop/icon.png",
+                                "ldpi" : "C:/Users/admin/Desktop/icon.png",
+                                "mdpi" : "C:/Users/z/Desktop/icon.png",
+                                "xhdpi" : "C:/Users/z/Desktop/icon.png",
+                                "xxhdpi" : "C:/Users/z/Desktop/icon.png"
+                            }
+                        }
+                    }
+                },
+                "oauth" : {},
+                "share" : {
+                    "weixin" : {
+                        "appid" : "",
+                        "UniversalLinks" : ""
+                    }
+                },
+                "statics" : {}
+            },
+            "icons" : {
+                "android" : {
+                    "hdpi" : "unpackage/res/icons/72x72.png",
+                    "xhdpi" : "unpackage/res/icons/96x96.png",
+                    "xxhdpi" : "unpackage/res/icons/144x144.png",
+                    "xxxhdpi" : "unpackage/res/icons/192x192.png"
+                },
+                "ios" : {
+                    "appstore" : "unpackage/res/icons/1024x1024.png",
+                    "ipad" : {
+                        "app" : "unpackage/res/icons/76x76.png",
+                        "app@2x" : "unpackage/res/icons/152x152.png",
+                        "notification" : "unpackage/res/icons/20x20.png",
+                        "notification@2x" : "unpackage/res/icons/40x40.png",
+                        "proapp@2x" : "unpackage/res/icons/167x167.png",
+                        "settings" : "unpackage/res/icons/29x29.png",
+                        "settings@2x" : "unpackage/res/icons/58x58.png",
+                        "spotlight" : "unpackage/res/icons/40x40.png",
+                        "spotlight@2x" : "unpackage/res/icons/80x80.png"
+                    },
+                    "iphone" : {
+                        "app@2x" : "unpackage/res/icons/120x120.png",
+                        "app@3x" : "unpackage/res/icons/180x180.png",
+                        "notification@2x" : "unpackage/res/icons/40x40.png",
+                        "notification@3x" : "unpackage/res/icons/60x60.png",
+                        "settings@2x" : "unpackage/res/icons/58x58.png",
+                        "settings@3x" : "unpackage/res/icons/87x87.png",
+                        "spotlight@2x" : "unpackage/res/icons/80x80.png",
+                        "spotlight@3x" : "unpackage/res/icons/120x120.png"
+                    }
+                }
+            }
+        },
+        "nativePlugins" : {},
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "quickapp" : {},
+    "mp-weixin" : {
+        "appid" : "",
+        "setting" : {
+            "urlCheck" : false
+        },
+        "usingComponents" : true,
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "mp-alipay" : {
+        "usingComponents" : true,
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "mp-baidu" : {
+        "usingComponents" : true,
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "mp-toutiao" : {
+        "usingComponents" : true,
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "uniStatistics" : {
+        "enable" : false,
+        "version" : "2"
+    },
+    "vueVersion" : "3",
+    "fallbackLocale" : "zh-Hans",
+    "h5" : {
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "mp-jd" : {
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "mp-kuaishou" : {
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "mp-lark" : {
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "mp-qq" : {
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "quickapp-webview-huawei" : {
+        "uniStatistics" : {
+            "enable" : false
+        }
+    },
+    "quickapp-webview-union" : {
+        "uniStatistics" : {
+            "enable" : false
+        }
+    }
+}

+ 6 - 0
src/mode/AjaxResult.ts

@@ -0,0 +1,6 @@
+interface AjaxResult<T> {
+  code: string
+  msg: string
+  data: T
+}
+export default AjaxResult

+ 18 - 0
src/mode/Chat.ts

@@ -0,0 +1,18 @@
+interface Chat {
+  id: string;
+  name: string;
+  avatar: string;
+  type: string;
+  // lastMessage: string;
+  // lastTime?: number;
+  unreadCount: number;
+  //是否在加载中(历史记录)
+  isLoading?: boolean;
+  //历史记录加载标志,每个chat只加载一次
+  loaded?: boolean;
+  top?: boolean;
+  //对方是否已读
+  lastReadTime?: number
+}
+
+export default Chat;

+ 7 - 0
src/mode/ChatSimple.ts

@@ -0,0 +1,7 @@
+interface ChatSimple {
+	id?: string;
+	name: string;
+	avatar: string;
+	type?: string;
+}
+export default ChatSimple;

+ 12 - 0
src/mode/Collect.ts

@@ -0,0 +1,12 @@
+import type Extend from "@/mode/Extend";
+
+interface Collect {
+  id?: string;
+  fromId?: string;
+  sendTime?: number;
+  content: string;
+  extend?: Extend;
+  messageType: string;
+  createTime?: string;
+}
+export default Collect;

+ 9 - 0
src/mode/Dept.ts

@@ -0,0 +1,9 @@
+interface Dept {
+  id: string;
+  name: string;
+  avatar: string;
+  parentId: string;
+  children: Array<Dept>;
+}
+
+export default Dept;

+ 7 - 0
src/mode/Details.ts

@@ -0,0 +1,7 @@
+interface Details {
+  id: string
+  goodsName: string
+  price: string
+  webUrl: string
+}
+export default Details

+ 12 - 0
src/mode/Extend.ts

@@ -0,0 +1,12 @@
+import type Message from "@/mode/Message";
+
+interface Extend {
+  fileName?: string;
+  url?: string;
+  time?: string;
+  atUserIds?: string[];
+  atAll?: boolean;
+  quoteMessage?: Message|undefined;
+  messageList?: Array<Message>;
+}
+export default Extend;

+ 10 - 0
src/mode/Friend.ts

@@ -0,0 +1,10 @@
+interface Friend {
+  id?: string;
+  userId?: string;
+  friendId?: string;
+  state?: string;
+  message?: string;
+  createBy?: string;
+  createTime?: string;
+}
+export default Friend;

+ 12 - 0
src/mode/Group.ts

@@ -0,0 +1,12 @@
+interface Group {
+  id: string;
+  name: string;
+  avatar: string;
+  master: string;
+  openInvite: string;
+  inviteCheck: string;
+  prohibition: string;
+  prohibitFriend: string;
+  announcement: string;
+}
+export default Group;

+ 12 - 0
src/mode/GroupInvite.ts

@@ -0,0 +1,12 @@
+interface GroupInvite {
+  id: string;
+  groupId: string;
+  fromId: string;
+  userId: string;
+  checkUserId: string;
+  checkMessage: string;
+  waitCheck: string;
+  checkResult: string;
+  createTime: string;
+}
+export default GroupInvite;

+ 6 - 0
src/mode/Immunity.ts

@@ -0,0 +1,6 @@
+interface Immunity {
+  id?: string
+  userId: string
+  chatId: string
+}
+export default Immunity

+ 23 - 0
src/mode/Message.ts

@@ -0,0 +1,23 @@
+import type Extend from "@/mode/Extend";
+
+interface Message {
+  //消息id,雪花id,有序增长
+  id?: string;
+  //消息文件类型 文本|附件|ping|语音
+  messageType: string;
+  //聊天室id
+  chatId: string;
+  //消息发送人
+  fromId: string;
+  //是否是本人
+  mine?: boolean;
+  //消息内容
+  content: string;
+  //消息时间
+  timestamp?: number;
+  //消息类型:私聊|群聊
+  type: string;
+  //扩展
+  extend?: Extend;
+}
+export default Message;

+ 10 - 0
src/mode/Receipt.ts

@@ -0,0 +1,10 @@
+/**
+ * 接受到的消息回执
+ */
+interface Receipt {
+  chatId: string;
+  userId: string;
+  timestamp: number;
+  type: string;
+}
+export default Receipt;

+ 12 - 0
src/mode/Setting.ts

@@ -0,0 +1,12 @@
+interface Setting {
+  canAddFriend: string;
+  addFriendValidate: string;
+  canSendMessage: string;
+  //声音提醒
+  canSoundRemind: string;
+  canVoiceRemind: string;
+  showMobile: string;
+  showEmail: string;
+  createBy: string;
+}
+export default Setting;

+ 11 - 0
src/mode/User.ts

@@ -0,0 +1,11 @@
+interface User {
+  id: string;
+  name: string;
+  mobile: string;
+  email: string;
+  avatar: string;
+  deptId: string;
+  sex: string;
+  canAddFriend: boolean;
+}
+export default User;

+ 6 - 0
src/mode/UserSimple.ts

@@ -0,0 +1,6 @@
+interface UserSimple {
+  id?:string
+  name: string
+  avatar: string
+}
+export default UserSimple

+ 0 - 0
src/pages.json


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