├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets ├── database.png ├── demo1.png ├── demo2.png ├── demo3.png ├── demo4.png ├── demo5.png ├── electron1.png └── electron2.png ├── client ├── .browserslistrc ├── .env.dev ├── .env.in ├── .env.out ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── babel.config.js ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── mime │ │ ├── doc.png │ │ ├── docx.png │ │ ├── exe.png │ │ ├── img.png │ │ ├── other.png │ │ ├── pdf.png │ │ ├── ppt.png │ │ ├── rar.png │ │ ├── txt.png │ │ ├── xls.png │ │ ├── xlsx.png │ │ └── zip.png ├── src │ ├── App.vue │ ├── api │ │ ├── apis │ │ │ ├── index.ts │ │ │ └── modules │ │ │ │ ├── friend.ts │ │ │ │ ├── group.ts │ │ │ │ └── user.ts │ │ └── axios │ │ │ ├── index.ts │ │ │ └── interceptors.ts │ ├── assets │ │ ├── friend.png │ │ ├── group.png │ │ └── send.png │ ├── common │ │ ├── index.ts │ │ └── theme.ts │ ├── components │ │ ├── Avatar.vue │ │ ├── Contact.vue │ │ ├── ContactModal.vue │ │ ├── Emoji.vue │ │ ├── Entry.vue │ │ ├── Login.vue │ │ ├── Message.vue │ │ ├── Nav.vue │ │ ├── Panel.vue │ │ ├── Room.vue │ │ └── Search.vue │ ├── index.d.ts │ ├── main.ts │ ├── plugins │ │ └── ant-desigin.ts │ ├── router │ │ └── index.ts │ ├── store │ │ ├── index.ts │ │ └── modules │ │ │ ├── app │ │ │ ├── actions.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── mutation-types.ts │ │ │ ├── mutations.ts │ │ │ └── state.ts │ │ │ └── chat │ │ │ ├── actions.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── mutation-types.ts │ │ │ ├── mutations.ts │ │ │ └── state.ts │ ├── styles │ │ ├── coverAntd.scss │ │ ├── index.scss │ │ └── theme.scss │ ├── types │ │ ├── chat.d.ts │ │ ├── shims-tsx.d.ts │ │ ├── shims-vue-expand.d.ts │ │ ├── shims-vue.d.ts │ │ └── user.d.ts │ ├── utils │ │ └── common.ts │ └── views │ │ └── Chat.vue ├── tsconfig.json ├── vue.config.js └── yarn.lock ├── deploy.md ├── node_modules └── .yarn-integrity ├── server ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── public │ ├── avatar │ │ ├── avatar1.png │ │ ├── avatar10.png │ │ ├── avatar11.png │ │ ├── avatar12.png │ │ ├── avatar13.png │ │ ├── avatar14.png │ │ ├── avatar15.png │ │ ├── avatar16.png │ │ ├── avatar17.png │ │ ├── avatar18.png │ │ ├── avatar19.png │ │ ├── avatar2.png │ │ ├── avatar20.png │ │ ├── avatar3.png │ │ ├── avatar4.png │ │ ├── avatar5.png │ │ ├── avatar6.png │ │ ├── avatar7.png │ │ ├── avatar8.png │ │ ├── avatar9.png │ │ └── robot.png │ └── static │ │ ├── file │ │ └── info.md │ │ └── image │ │ └── info.md ├── src │ ├── app.module.ts │ ├── common │ │ ├── constant │ │ │ ├── global.ts │ │ │ └── rcode.ts │ │ ├── filters │ │ │ ├── http-exception.filter.ts │ │ │ └── ws-exception.filter.ts │ │ ├── guards │ │ │ └── WsJwtGuard.ts │ │ ├── interceptor │ │ │ └── response.interceptor.ts │ │ ├── middleware │ │ │ ├── elasticsearch.ts │ │ │ └── logger.middleware.ts │ │ └── tool │ │ │ └── utils.ts │ ├── fix.ts │ ├── main.ts │ └── modules │ │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ ├── constants.ts │ │ ├── jwt.strategy.ts │ │ └── local.strategy.ts │ │ ├── chat │ │ ├── chat.gateway.ts │ │ ├── chat.module.ts │ │ └── index.d.ts │ │ ├── friend │ │ ├── entity │ │ │ ├── friend.entity.ts │ │ │ └── friendMessage.entity.ts │ │ ├── friend.controller.ts │ │ ├── friend.module.ts │ │ └── friend.service.ts │ │ ├── group │ │ ├── entity │ │ │ ├── group.entity.ts │ │ │ └── groupMessage.entity.ts │ │ ├── group.controller.ts │ │ ├── group.module.ts │ │ └── group.service.ts │ │ └── user │ │ ├── entity │ │ └── user.entity.ts │ │ ├── user.controller.ts │ │ ├── user.module.ts │ │ └── user.service.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock ├── webSocket建立流程.md └── yarn.lock /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | "./client", 4 | "./server" 5 | ] 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 BoBoooooo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tyloo-Chat(仿wechat) 2 | [![author](https://img.shields.io/badge/author-BoBoooooo-blue.svg)](https://github.com/BoBoooooo) 3 | [![author](https://img.shields.io/github/languages/top/BoBoooooo/tyloo-chat)](https://github.com/BoBoooooo/tyloo-chat) 4 | [![Node.js Version](https://img.shields.io/badge/node.js-10.16.3-blue.svg)](http://nodejs.org/download) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/BoBoooooo/tyloo-chat/LICENSE) 6 | [![author](https://img.shields.io/github/stars/BoBoooooo/tyloo-chat?style=social)](https://github.com/BoBoooooo/tyloo-chat) 7 | 8 | ## 线上访问地址暂时关闭 9 | 10 | ## 说明 11 | 12 | 本项目fork自[genal-chat]('https://github.com/genaller/genal-chat.git')做了优化升级,感谢大佬`Genal`开源提供思路! 13 | 14 | 目前还在抽空持续优化中,敬请期待!!! 15 | 16 | 觉得还不错的话可以点个Star鼓励一下!!! 17 | 18 | ## 🚀 Electron版本客户端已出炉,详见release 19 | 20 | ## 部分功能截图 21 | - 整体界面 22 | 23 | ![](./assets/demo1.png) 24 | - 通讯录 25 | 26 | ![](./assets/demo2.png) 27 | 28 | - 群聊功能(群成员列表,在线状态,支持添加群成员) 29 | ![](./assets/demo3.png) 30 | - 会话列表(置顶/删除) 31 | 32 | ![](./assets/demo5.png) 33 | - 消息撤回功能 34 | 35 | ![](./assets/demo4.png) 36 | 37 | ## Electron版本客户端(位于electron_version分支) 38 | - windows版本(exe) 39 | 40 | ![](./assets/electron1.png) 41 | 42 | - mac版本(dmg) 43 | 44 | ![](./assets/electron2.png) 45 | ## Feature 46 | - 用户登陆注册 (支持嵌入第三方系统单点登陆) 47 | - [单点登陆地址](http://server.boboooooo.top:9998),登录后点击右上角在线交流按钮 48 | - 群聊 (类似qq群) 49 | - 邀请好友加入群聊 50 | - 修改群名/群公告 51 | - 好友功能 52 | - 通讯录功能(支持接入第三方系统组织架构,直接发起聊天) 53 | - 聊天功能 54 | - Emoji表情包 55 | - 图片发送/图片预览 56 | - 发送附件 57 | - 消息分页 58 | - 消息撤回/复制 59 | - 自定义主题 60 | - 会话置顶/删除 61 | - 重连提醒 62 | - **智能助手(默认,位于main分支,采用ES搜索引擎需要手动创建ES词库)** 63 | - **第三方API机器人(当前线上demo版本,位于feature_APIROBOT分支)** 64 | - **Electron版本(位于electron_version分支,支持生成dmg,exe客户端)** 65 | ## 技术栈 66 | - **前端** 67 | - **vue cli 4.x** 68 | - **Antd for vue** 69 | - **后端** 70 | - **NestJS** 71 | - **TypeORM** 72 | - **Mysql** 73 | - **Socket.io** 74 | 75 | - **ElasticSearch** ES搜索引擎(用于机器人快捷自动回复) 76 | - **[Nodejieba](https://github.com/yanyiwu/nodejieba)** node版本中文分词器 77 | ## 数据库表结构设计 78 | ![](./assets/database.png) 79 | 80 | ## 环境准备 81 | - mysql 82 | - chat数据库 (需要手动创建,**注意数据库编码格式为 `utf8bm64``utf8bm64``utf8bm64` !!!**) 83 | - node v10.16.3 84 | 85 | ## 拉取代码时注意事项 86 | 87 | ``` 88 | // windows系统需要配置一下,提交时转换为LF,检出时不转换 89 | git config --global core.autocrlf input 90 | ``` 91 | 92 | ``` 93 | // 设置为区分大小写 94 | git config core.ignorecase false 95 | ``` 96 | 97 | ## 运行项目 98 | ```js 99 | // client 100 | cd client 101 | cnpm i 102 | npm start 103 | ``` 104 | 105 | ```js 106 | // server 107 | cd server 108 | cnpm i 109 | npm run start 110 | ``` 111 | 112 | ## 如何部署 113 | 114 | [Deploy](./deploy.md) 115 | 116 | [CentOS下部署聊天室](https://notes.zhangxiaocai.cn/posts/39142aea.html) 117 | 118 | ## 第三方集成/单点登陆 119 | 120 | - 第三方系统里嵌入如下跳转代码,需要携带`userId`以及`username`参数 121 | 122 | ``` javascript 123 | let chatUrl // 当前聊天室客户端地址 124 | let userId // 当前系统用户userId 125 | let username // 当前系统用户昵称 126 | 127 | window.open(`${chatUrl}?userId=${userId}&username=${username}`); 128 | 129 | ``` 130 | 131 | - 聊天室获取参数并自动完成登陆(若为首次登陆会自动注册账号) 132 | 133 | - 设置聊天室client `VUE_APP_ORG_URL` 为获取第三方系统组织架构的接口地址 134 | 135 | - 设置VUE_APP_ORG_URL 136 | ``` javascript 137 | 138 | // .env.xxx 139 | // 此接口可以获取到第三方系统的所有部门和人员信息,注意为嵌套tree结构 140 | VUE_APP_ORG_URL=http://127.0.0.1:8080/api/getDeptUsersTree 141 | 142 | ``` 143 | 144 | - 切换到联系人界面自动发出请求 145 | ``` javascript 146 | // Contact.vue 147 | axios.post(process.env.VUE_APP_ORG_URL).then((res) => { 148 | this.organizationArr = res.data.data; 149 | }); 150 | ``` 151 | 152 | - 返回值格式如下 153 | ``` javascript 154 | interface node { 155 | id: string; // id 156 | label: string;// 名称 157 | flag: boolean;// 是否有下级结点 158 | children: node[];// 下级结点 159 | } 160 | ``` 161 | - 若不需要集成第三方组织架构清空`VUE_APP_ORG_URL`即可,其他情况自行定制修改。 162 | 163 | ## 思路概述 164 | [webSocket建立流程](./webSocket建立流程.md) 165 | ## TODO 166 | - `@功能实现` 167 | - `消息转发` 168 | - `代码性能优化` 169 | - `群聊功能继续完善` 170 | - `微信快捷登陆` 171 | - `Electron客户端检查更新` 172 | 173 | ## 交流群 174 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /assets/database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/assets/database.png -------------------------------------------------------------------------------- /assets/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/assets/demo1.png -------------------------------------------------------------------------------- /assets/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/assets/demo2.png -------------------------------------------------------------------------------- /assets/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/assets/demo3.png -------------------------------------------------------------------------------- /assets/demo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/assets/demo4.png -------------------------------------------------------------------------------- /assets/demo5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/assets/demo5.png -------------------------------------------------------------------------------- /assets/electron1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/assets/electron1.png -------------------------------------------------------------------------------- /assets/electron2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/assets/electron2.png -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /client/.env.dev: -------------------------------------------------------------------------------- 1 | #本地开发环境 2 | NODE_ENV=development 3 | 4 | #公共组件使用cdn加速 5 | VUE_APP_CDN=false 6 | 7 | #后台地址 8 | VUE_APP_API_URL=http://localhost:3000 9 | 10 | #第三方组织架构地址 11 | VUE_APP_ORG_URL= -------------------------------------------------------------------------------- /client/.env.in: -------------------------------------------------------------------------------- 1 | #生产环境 2 | NODE_ENV=production 3 | 4 | #公共组件使用cdn加速 5 | VUE_APP_CDN=false 6 | 7 | #后台地址 8 | VUE_APP_API_URL=http://server.boboooooo.top:3000 9 | 10 | #第三方组织架构地址 11 | VUE_APP_ORG_URL= -------------------------------------------------------------------------------- /client/.env.out: -------------------------------------------------------------------------------- 1 | #生产环境 2 | NODE_ENV=production 3 | 4 | #公共组件使用cdn加速 5 | VUE_APP_CDN=true 6 | 7 | #后台地址 8 | VUE_APP_API_URL=http://server.boboooooo.top:3000 9 | 10 | #第三方组织架构地址 11 | VUE_APP_ORG_URL= -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/airbnb', 9 | '@vue/typescript', 10 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 11 | ], 12 | parserOptions: { 13 | parser: '@typescript-eslint/parser', 14 | ecmaFeatures: { 15 | legacyDecorators: true, 16 | }, 17 | }, 18 | rules: { 19 | // "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 20 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 21 | 'vue/no-unused-component': 'off', 22 | 'no-console': 'off', 23 | 'no-irregular-whitespace': 'off', 24 | 'prefer-spread': 0, 25 | 'no-plusplus': 0, 26 | 'max-len': 0, 27 | 'eslint-disable-next-line': 'off', 28 | // 允许class中方法不使用this 29 | 'class-methods-use-this': 'off', 30 | // 允许下划线变量命名 31 | 'no-underscore-dangle': 'off', 32 | // 不强制返回值 33 | 'consistent-return': 'off', 34 | camelcase: 'off', 35 | // 允许循环引入 36 | 'import/no-cycle': 'off', 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | package-lock.json -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 140, 7 | "endOfLine": "auto", 8 | "arrowParens": "always" 9 | } 10 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | yarn lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/app'], 3 | }; 4 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tyloo-chat-client", 3 | "version": "2.1.7", 4 | "author": "boboooooo159@gmail.com", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "vue-cli-service serve --mode dev", 8 | "build": "vue-cli-service build --mode out", 9 | "lint": "vue-cli-service lint" 10 | }, 11 | "dependencies": { 12 | "@types/js-cookie": "^2.2.6", 13 | "@types/socket.io-client": "^1.4.33", 14 | "ant-design-vue": "^1.6.2", 15 | "axios": "^0.19.2", 16 | "cnchar": "^2.2.7", 17 | "core-js": "^2.6.5", 18 | "js-cookie": "^2.2.1", 19 | "less-loader": "^6.1.2", 20 | "localforage": "^1.9.0", 21 | "moment": "^2.27.0", 22 | "sass": "^1.26.9", 23 | "sass-loader": "^8.0.2", 24 | "socket.io-client": "^2.3.0", 25 | "v-contextmenu": "^2.9.0", 26 | "v-viewer": "^1.5.1", 27 | "vue": "^2.6.11", 28 | "vue-class-component": "^7.2.3", 29 | "vue-property-decorator": "^8.4.2", 30 | "vue-router": "^3.2.0", 31 | "vue-runtime-helpers": "^1.1.2", 32 | "vuex": "^3.4.0", 33 | "vuex-class": "^0.3.2", 34 | "lodash": "^4.17.20" 35 | }, 36 | "devDependencies": { 37 | "@types/lodash": "^4.14.165", 38 | "@typescript-eslint/eslint-plugin": "^2.26.0", 39 | "@typescript-eslint/parser": "^2.26.0", 40 | "@vue/cli-plugin-babel": "^3.9.2", 41 | "@vue/cli-plugin-eslint": "^3.9.2", 42 | "@vue/cli-plugin-router": "^4.4.0", 43 | "@vue/cli-plugin-typescript": "~4.3.0", 44 | "@vue/cli-plugin-vuex": "^4.4.0", 45 | "@vue/cli-service": "^3.9.2", 46 | "@vue/eslint-config-airbnb": "^4.0.1", 47 | "@vue/eslint-config-typescript": "^5.0.2", 48 | "babel-eslint": "^10.0.2", 49 | "compression-webpack-plugin": "^4.0.0", 50 | "eslint": "^6.0.1", 51 | "eslint-plugin-import": "^2.18.0", 52 | "eslint-config-prettier": "^8.1.0", 53 | "eslint-plugin-vue": "^5.2.3", 54 | "eslint-plugin-prettier": "^3.3.1", 55 | "typescript": "~3.8.3", 56 | "vue-template-compiler": "^2.6.11", 57 | "webpack": "^4.5.0", 58 | "webpack-bundle-analyzer": "^3.8.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | TylooChat聊天室 10 | 11 | 12 | 15 |
16 | <% if (process.env.VUE_APP_CDN === 'true') { %> 17 | <% for(var css of htmlWebpackPlugin.options.cdn.css) { %> 18 | 19 | <% } %> 20 | <% for(var js of htmlWebpackPlugin.options.cdn.js) { %> 21 | 22 | <% } %> 23 | <% } %> 24 | 25 | 26 | -------------------------------------------------------------------------------- /client/public/mime/doc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/doc.png -------------------------------------------------------------------------------- /client/public/mime/docx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/docx.png -------------------------------------------------------------------------------- /client/public/mime/exe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/exe.png -------------------------------------------------------------------------------- /client/public/mime/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/img.png -------------------------------------------------------------------------------- /client/public/mime/other.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/other.png -------------------------------------------------------------------------------- /client/public/mime/pdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/pdf.png -------------------------------------------------------------------------------- /client/public/mime/ppt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/ppt.png -------------------------------------------------------------------------------- /client/public/mime/rar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/rar.png -------------------------------------------------------------------------------- /client/public/mime/txt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/txt.png -------------------------------------------------------------------------------- /client/public/mime/xls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/xls.png -------------------------------------------------------------------------------- /client/public/mime/xlsx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/xlsx.png -------------------------------------------------------------------------------- /client/public/mime/zip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/public/mime/zip.png -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 42 | 65 | -------------------------------------------------------------------------------- /client/src/api/apis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './modules/group'; 2 | export * from './modules/user'; 3 | export * from './modules/friend'; 4 | -------------------------------------------------------------------------------- /client/src/api/apis/modules/friend.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/api/axios'; 2 | 3 | /** 4 | * 群分页消息 5 | * @param params 6 | */ 7 | // eslint-disable-next-line import/prefer-default-export 8 | export async function getFriendMessage(params: PagingParams) { 9 | // eslint-disable-next-line no-return-await 10 | return await axios.get('/friend/friendMessages', { 11 | params, 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /client/src/api/apis/modules/group.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/api/axios'; 2 | 3 | /** 4 | * 群名模糊搜索用户 5 | * @param string 6 | */ 7 | export function getGroupsByName(groupName: string) { 8 | return axios.get(`/group/findByName?groupName=${groupName}`); 9 | } 10 | 11 | /** 12 | * 群分页消息 13 | * @param params 14 | */ 15 | export async function getGroupMessages(params: PagingParams) { 16 | // eslint-disable-next-line no-return-await 17 | return await axios.get('/group/groupMessages', { 18 | params, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /client/src/api/apis/modules/user.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/api/axios'; 2 | 3 | /** 4 | * 更新用户名 5 | * @param username 6 | */ 7 | export const patchUserName = (username: string) => axios.patch(`/user/username?username=${username}`); 8 | 9 | /** 10 | * 更新用户密码 11 | * @param password 12 | * 13 | */ 14 | export const patchPassword = (password: string) => axios.patch(`/user/password?password=${password}`); 15 | 16 | /** 17 | * 用户名模糊搜索用户 18 | * @param username 19 | */ 20 | export function getUsersByName(username: string) { 21 | return axios.get(`/user/findByName?username=${username}`); 22 | } 23 | 24 | /** 25 | * 用户头像上传 26 | * @param params 27 | */ 28 | export function setUserAvatar(params: FormData) { 29 | return axios.post('/user/avatar', params, { 30 | headers: { 31 | 'Content-Type': 'multipart/form-data', 32 | }, 33 | }); 34 | } 35 | 36 | /** 37 | * 删除用户 38 | * @param params 39 | */ 40 | export function deleteUser(params: any) { 41 | return axios.delete('/user', { params }); 42 | } 43 | -------------------------------------------------------------------------------- /client/src/api/axios/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios'; 2 | import { requestSuccess, requestFail, responseSuccess, responseFail } from './interceptors'; 3 | 4 | const fetch: AxiosInstance = axios.create({ 5 | timeout: 60000, // 超时时间一分钟 6 | baseURL: process.env.VUE_APP_API_URL, 7 | headers: { 8 | 'Cache-Control': 'no-cache', 9 | Pragma: 'no-cache', 10 | }, 11 | // 不携带cookie 12 | withCredentials: false, 13 | }); 14 | 15 | fetch.interceptors.request.use(requestSuccess, requestFail); 16 | fetch.interceptors.response.use(responseSuccess, responseFail); 17 | 18 | export default fetch; 19 | -------------------------------------------------------------------------------- /client/src/api/axios/interceptors.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import cookie from 'js-cookie'; 3 | import Vue from 'vue'; 4 | import store from '@/store/index'; 5 | import { CLEAR_USER, SET_LOADING } from '../../store/modules/app/mutation-types'; 6 | 7 | // 请求拦截器 8 | export const requestSuccess = (request: AxiosRequestConfig) => { 9 | const token = cookie.get('token'); 10 | // eslint-disable-next-line no-param-reassign 11 | request.headers.token = token; 12 | return request; 13 | }; 14 | 15 | export const requestFail = (error: any) => { 16 | store.commit(`app/${SET_LOADING}`, false); 17 | Vue.prototype.$message.error(error.message); 18 | return Promise.reject(error); 19 | }; 20 | 21 | // 接收拦截器 22 | export const responseSuccess = (response: AxiosResponse) => { 23 | // token过期时清空登录态重新登录 24 | const { data } = response; 25 | if (data.code === 401) { 26 | Vue.prototype.$message.error(data.msg); 27 | store.commit(`app/${CLEAR_USER}`); 28 | setTimeout(() => { 29 | window.location.reload(); 30 | }, 2000); 31 | } 32 | return response; 33 | }; 34 | 35 | export const responseFail = (error: any) => { 36 | store.commit(`app/${SET_LOADING}`, false); 37 | Vue.prototype.$message.error(error.message); 38 | return Promise.reject(error); 39 | }; 40 | -------------------------------------------------------------------------------- /client/src/assets/friend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/src/assets/friend.png -------------------------------------------------------------------------------- /client/src/assets/group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/src/assets/group.png -------------------------------------------------------------------------------- /client/src/assets/send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/client/src/assets/send.png -------------------------------------------------------------------------------- /client/src/common/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: 默认群组,需结合后台 (用户新建后默认进入该群组) 3 | * @copyright: BoBo 4 | * @author: BoBo 5 | * @Date: 2020年11月05 16:40:11 6 | */ 7 | 8 | // 默认群组Id 9 | let bg = ''; 10 | if (process.env.VUE_APP_CDN !== 'true' && process.env.NODE_ENV === 'production') { 11 | // 默认背景图片 12 | bg = 'http://11.176.37.20:8090/img/secret.jpg'; 13 | } else { 14 | bg = 'https://pic.downk.cc/item/5fc744ea394ac5237897a81d.jpg'; 15 | } 16 | 17 | export const DEFAULT_GROUP = 'group'; 18 | 19 | export const DEFAULT_BACKGROUND = bg; 20 | // 默认机器人Id 21 | export const DEFAULT_ROBOT = 'robot'; 22 | 23 | // 图片/附件请求路径 24 | export const IMAGE_SAVE_PATH = '/static/image/'; 25 | export const FILE_SAVE_PATH = '/static/file/'; 26 | 27 | // MIME类型 28 | export const MIME_TYPE = ['xls', 'xlsx', 'doc', 'docx', 'exe', 'pdf', 'ppt', 'txt', 'zip', 'img', 'rar']; 29 | // 图片类型 30 | export const IMAGE_TYPE = ['png', 'jpg', 'jpeg', 'gif']; 31 | -------------------------------------------------------------------------------- /client/src/common/theme.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: 背景图片 3 | * @author: BoBo 4 | * @copyright: BoBo 5 | * @Date: 2020-12-02 15:33:37 6 | */ 7 | export default [ 8 | { 9 | url: 'https://pic.downk.cc/item/5fc7432a394ac52378970d9e.jpg', 10 | name: '壁纸一', 11 | }, 12 | { 13 | url: 'https://pic.downk.cc/item/5fc7438c394ac5237897328d.jpg', 14 | name: '壁纸二', 15 | }, 16 | { 17 | url: 'https://pic.downk.cc/item/5fc744ca394ac52378979ca1.jpg', 18 | name: '壁纸三', 19 | }, 20 | { 21 | url: 'https://pic.downk.cc/item/5fc744d6394ac5237897a1f4.jpg', 22 | name: '壁纸四', 23 | }, 24 | { 25 | url: 'https://pic.downk.cc/item/5fc744e1394ac5237897a560.jpg', 26 | name: '壁纸五', 27 | }, 28 | { 29 | url: 'https://pic.downk.cc/item/5fc744ea394ac5237897a81d.jpg', 30 | name: '壁纸六', 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /client/src/components/Avatar.vue: -------------------------------------------------------------------------------- 1 | 7 | 53 | 54 | 116 | 167 | -------------------------------------------------------------------------------- /client/src/components/Contact.vue: -------------------------------------------------------------------------------- 1 | 7 | 38 | 39 | 158 | 159 | 205 | -------------------------------------------------------------------------------- /client/src/components/ContactModal.vue: -------------------------------------------------------------------------------- 1 | 7 | 51 | 179 | 185 | -------------------------------------------------------------------------------- /client/src/components/Emoji.vue: -------------------------------------------------------------------------------- 1 | 7 | 99 | 100 | 110 | 133 | -------------------------------------------------------------------------------- /client/src/components/Entry.vue: -------------------------------------------------------------------------------- 1 | 7 | 55 | 56 | 313 | 388 | -------------------------------------------------------------------------------- /client/src/components/Login.vue: -------------------------------------------------------------------------------- 1 | 7 | 49 | 50 | 104 | 115 | -------------------------------------------------------------------------------- /client/src/components/Nav.vue: -------------------------------------------------------------------------------- 1 | 7 | 108 | 109 | 255 | 463 | -------------------------------------------------------------------------------- /client/src/components/Panel.vue: -------------------------------------------------------------------------------- 1 | 7 | 88 | 89 | 217 | 246 | -------------------------------------------------------------------------------- /client/src/components/Search.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 229 | 262 | -------------------------------------------------------------------------------- /client/src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'ant-design-vue/lib/locale-provider/zh_CN'; 2 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: Main js 入口文件 3 | * @copyright: BoBo 4 | * @author: BoBo 5 | * @Date: 2020年11月05 16:40:11 6 | */ 7 | 8 | import Vue from 'vue'; 9 | import Viewer from 'v-viewer'; // 图片预览插件 10 | import moment from 'moment'; // 引入moment 11 | import contentmenu from 'v-contextmenu'; 12 | import lodash from 'lodash'; 13 | import localforage from 'localforage'; 14 | import App from './App.vue'; 15 | import router from './router'; 16 | import store from './store'; 17 | // eslint-disable-next-line import/no-extraneous-dependencies 18 | import 'viewerjs/dist/viewer.css'; 19 | import './plugins/ant-desigin'; // 引入ant-desigin 20 | import 'v-contextmenu/dist/index.css'; 21 | 22 | Vue.use(contentmenu); 23 | Vue.config.productionTip = false; 24 | // 使用中文时间 25 | Vue.prototype.$moment = moment; 26 | // lodash 27 | Vue.prototype.$lodash = lodash; 28 | 29 | // localforage https://localforage.docschina.org/ 30 | // 基于IndexedDB二次封装 31 | Vue.prototype.$localforage = localforage; 32 | Vue.use(Viewer, { 33 | defaultOptions: { 34 | navbar: false, 35 | title: false, 36 | toolbar: { 37 | zoomIn: 1, 38 | zoomOut: 1, 39 | oneToOne: 4, 40 | reset: 4, 41 | prev: 0, 42 | next: 0, 43 | rotateLeft: 4, 44 | rotateRight: 4, 45 | flipHorizontal: 4, 46 | flipVertical: 4, 47 | }, 48 | }, 49 | }); 50 | 51 | new Vue({ 52 | router, 53 | store, 54 | render: (h) => h(App), 55 | }).$mount('#app'); 56 | -------------------------------------------------------------------------------- /client/src/plugins/ant-desigin.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: 按需引入antd 3 | * @copyright: BoBo 4 | * @author: BoBo 5 | * @Date: 2020年11月05 16:40:11 6 | */ 7 | import Vue from 'vue'; 8 | import 'ant-design-vue/dist/antd.less'; 9 | import { 10 | message, 11 | Button, 12 | Input, 13 | Modal, 14 | Form, 15 | Checkbox, 16 | Icon, 17 | Tabs, 18 | Popover, 19 | Dropdown, 20 | Menu, 21 | Avatar, 22 | Card, 23 | Select, 24 | Upload, 25 | Tooltip, 26 | Drawer, 27 | Popconfirm, 28 | Badge, 29 | Tree, 30 | Collapse, 31 | Transfer, 32 | ConfigProvider, 33 | Alert, 34 | } from 'ant-design-vue'; 35 | 36 | Vue.use(Avatar); 37 | Vue.use(Button); 38 | Vue.use(Input); 39 | Vue.use(Modal); 40 | Vue.use(Form); 41 | Vue.use(Checkbox); 42 | Vue.use(Icon); 43 | Vue.use(Tabs); 44 | Vue.use(Popover); 45 | Vue.use(Dropdown); 46 | Vue.use(Menu); 47 | Vue.use(Card); 48 | Vue.use(Select); 49 | Vue.use(Upload); 50 | Vue.use(Tooltip); 51 | Vue.use(Drawer); 52 | Vue.use(Popconfirm); 53 | Vue.use(Badge); 54 | Vue.use(Tree); 55 | Vue.use(Collapse); 56 | Vue.use(Transfer); 57 | Vue.use(ConfigProvider); 58 | Vue.use(Alert); 59 | Vue.prototype.$message = message; 60 | -------------------------------------------------------------------------------- /client/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter, { RouteConfig } from 'vue-router'; 3 | 4 | Vue.use(VueRouter); 5 | 6 | const routes: Array = [ 7 | { 8 | path: '/', 9 | name: 'Chat', 10 | component: () => import('@/views/Chat.vue'), 11 | }, 12 | ]; 13 | 14 | const router = new VueRouter({ 15 | mode: 'history', 16 | base: process.env.BASE_URL, 17 | routes, 18 | }); 19 | 20 | export default router; 21 | -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex, { ModuleTree } from 'vuex'; 3 | // app 4 | import app from './modules/app'; 5 | import { AppState } from './modules/app/state'; 6 | // chat 7 | import chat from './modules/chat'; 8 | import { ChatState } from './modules/chat/state'; 9 | 10 | export type RootState = { 11 | app: AppState; 12 | chat: ChatState; 13 | }; 14 | Vue.use(Vuex); 15 | 16 | const modules: ModuleTree = { 17 | app, 18 | chat, 19 | }; 20 | 21 | export default new Vuex.Store({ 22 | modules, 23 | }); 24 | -------------------------------------------------------------------------------- /client/src/store/modules/app/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionTree } from 'vuex'; 2 | import axios from '@/api/axios'; 3 | import { processReturn } from '@/utils/common'; 4 | import { SET_USER, SET_TOKEN, SET_LOADING } from './mutation-types'; 5 | import { AppState } from './state'; 6 | import { RootState } from '../../index'; 7 | 8 | const actions: ActionTree = { 9 | async register({ commit }, payload) { 10 | commit(SET_LOADING, true); 11 | const res = await axios.post('/auth/register', { 12 | ...payload, 13 | }); 14 | const data = processReturn(res); 15 | commit(SET_LOADING, false); 16 | if (data) { 17 | commit(SET_USER, data.user); 18 | commit(SET_TOKEN, data.token); 19 | return data; 20 | } 21 | }, 22 | async login({ commit }, payload) { 23 | commit(SET_LOADING, true); 24 | const res = await axios.post('/auth/login', { 25 | ...payload, 26 | }); 27 | const data = processReturn(res); 28 | commit(SET_LOADING, false); 29 | if (data) { 30 | commit(SET_USER, data.user); 31 | commit(SET_TOKEN, data.token); 32 | return data; 33 | } 34 | }, 35 | }; 36 | 37 | export default actions; 38 | -------------------------------------------------------------------------------- /client/src/store/modules/app/getters.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree } from 'vuex'; 2 | import cookie from 'js-cookie'; 3 | import { AppState } from './state'; 4 | import { RootState } from '../../index'; 5 | 6 | const getters: GetterTree = { 7 | user(state) { 8 | // eslint-disable-next-line no-unused-expressions 9 | state.user; 10 | const user = cookie.get('user'); 11 | if (!user) { 12 | return {}; 13 | } 14 | state.user = JSON.parse(user); 15 | return state.user; 16 | }, 17 | mobile(state) { 18 | return state.mobile; 19 | }, 20 | background(state) { 21 | // eslint-disable-next-line no-unused-expressions 22 | state.background; 23 | return localStorage.getItem('background'); 24 | }, 25 | activeTabName(state) { 26 | return state.activeTabName; 27 | }, 28 | token(state) { 29 | return state.token; 30 | }, 31 | apiUrl(state) { 32 | return state.apiUrl; 33 | }, 34 | loading(state) { 35 | return state.loading; 36 | }, 37 | }; 38 | 39 | export default getters; 40 | -------------------------------------------------------------------------------- /client/src/store/modules/app/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import actions from './actions'; 3 | import mutations from './mutations'; 4 | import getters from './getters'; 5 | import state, { AppState } from './state'; 6 | import { RootState } from '../../index'; 7 | 8 | const app: Module = { 9 | namespaced: true, 10 | state, 11 | mutations, 12 | actions, 13 | getters, 14 | }; 15 | 16 | export default app; 17 | -------------------------------------------------------------------------------- /client/src/store/modules/app/mutation-types.ts: -------------------------------------------------------------------------------- 1 | export const SET_USER = 'set_user'; 2 | export const CLEAR_USER = 'clear_user'; 3 | export const SET_TOKEN = 'set_token'; 4 | export const SET_MOBILE = 'set_mobile'; 5 | export const SET_BACKGROUND = 'set_background'; 6 | export const SET_ACTIVETABNAME = 'set_activeTabName'; 7 | export const SET_LOADING = 'set_loading'; 8 | -------------------------------------------------------------------------------- /client/src/store/modules/app/mutations.ts: -------------------------------------------------------------------------------- 1 | import cookie from 'js-cookie'; 2 | import { MutationTree } from 'vuex'; 3 | import { SET_USER, CLEAR_USER, SET_TOKEN, SET_MOBILE, SET_BACKGROUND, SET_ACTIVETABNAME, SET_LOADING } from './mutation-types'; 4 | import { AppState } from './state'; 5 | 6 | const mutations: MutationTree = { 7 | [SET_USER](state, payload: User) { 8 | state.user = payload; 9 | // 数据持久化 10 | cookie.set('user', payload, { expires: 3650 }); 11 | }, 12 | 13 | [CLEAR_USER](state) { 14 | state.user = { 15 | userId: '', 16 | username: '', 17 | password: '', 18 | avatar: '', 19 | createTime: 0, 20 | }; 21 | cookie.set('user', ''); 22 | cookie.set('token', ''); 23 | }, 24 | 25 | [SET_TOKEN](state, payload) { 26 | state.token = payload; 27 | cookie.set('token', payload, { expires: 3 }); 28 | }, 29 | 30 | [SET_MOBILE](state, payload: boolean) { 31 | state.mobile = payload; 32 | }, 33 | 34 | [SET_BACKGROUND](state, payload: string) { 35 | state.background = payload; 36 | localStorage.setItem('background', payload); 37 | }, 38 | [SET_ACTIVETABNAME](state, payload: 'message' | 'contacts') { 39 | state.activeTabName = payload; 40 | }, 41 | [SET_LOADING](state, payload: boolean) { 42 | state.loading = payload; 43 | }, 44 | }; 45 | 46 | export default mutations; 47 | -------------------------------------------------------------------------------- /client/src/store/modules/app/state.ts: -------------------------------------------------------------------------------- 1 | import cookie from 'js-cookie'; 2 | 3 | export interface AppState { 4 | user: User; 5 | token: string; 6 | mobile: boolean; 7 | background: string; 8 | activeTabName: 'message' | 'contacts'; 9 | apiUrl: string; 10 | loading: boolean; 11 | } 12 | 13 | const appState: AppState = { 14 | user: { 15 | userId: '', 16 | username: '', 17 | password: '', 18 | avatar: '', 19 | createTime: 0, 20 | }, 21 | token: cookie.get('token') as string, 22 | mobile: false, 23 | background: '', 24 | activeTabName: 'message', 25 | apiUrl: process.env.VUE_APP_API_URL, // 后台api地址 26 | loading: false, // 全局Loading状态 27 | }; 28 | 29 | export default appState; 30 | -------------------------------------------------------------------------------- /client/src/store/modules/chat/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionTree } from 'vuex'; 2 | import io from 'socket.io-client'; 3 | import Vue from 'vue'; 4 | import localforage from 'localforage'; 5 | import { DEFAULT_GROUP } from '@/common/index'; 6 | import { SET_LOADING, CLEAR_USER } from '../app/mutation-types'; 7 | import { ChatState } from './state'; 8 | import { RootState } from '../../index'; 9 | import { 10 | SET_SOCKET, 11 | SET_DROPPED, 12 | ADD_GROUP_MESSAGE, 13 | ADD_FRIEND_MESSAGE, 14 | SET_GROUP_GATHER, 15 | SET_FRIEND_GATHER, 16 | SET_USER_GATHER, 17 | SET_ACTIVE_ROOM, 18 | DEL_GROUP, 19 | DEL_GROUP_MEMBER, 20 | DEL_FRIEND, 21 | ADD_UNREAD_GATHER, 22 | REVOKE_MESSAGE, 23 | USER_ONLINE, 24 | USER_OFFLINE, 25 | ADD_GROUP_MEMBER, 26 | UPDATE_USER_INFO, 27 | } from './mutation-types'; 28 | 29 | const actions: ActionTree = { 30 | // 初始化socket连接和监听socket事件 31 | async connectSocket({ commit, state, dispatch, rootState }) { 32 | const { user, token } = rootState.app; 33 | const socket: SocketIOClient.Socket = io.connect(`ws://${process.env.VUE_APP_API_URL.split('http://')[1]}`, { 34 | reconnection: true, 35 | query: { 36 | token, 37 | userId: user.userId, 38 | }, 39 | }); 40 | // token校验,失败则要求重新登录 41 | socket.on('unauthorized', (msg: string) => { 42 | Vue.prototype.$message.error(msg); 43 | // 清空token,socket 44 | commit(`app/${CLEAR_USER}`, {}, { root: true }); 45 | setTimeout(() => { 46 | window.location.reload(); 47 | }, 1000); 48 | }); 49 | 50 | socket.on('connect', async () => { 51 | console.log('连接成功'); 52 | // 获取聊天室所需所有信息 53 | socket.emit('chatData', token); 54 | 55 | // 先保存好socket对象 56 | commit(SET_SOCKET, socket); 57 | }); 58 | // 用户上线 59 | socket.on('userOnline', (data: any) => { 60 | console.log('userOnline', data); 61 | commit(USER_ONLINE, data.data); 62 | }); 63 | 64 | // 用户下线 65 | socket.on('userOffline', (data: any) => { 66 | console.log('userOffline', data); 67 | commit(USER_OFFLINE, data.data); 68 | }); 69 | 70 | // 新建群组 71 | socket.on('addGroup', (res: ServerRes) => { 72 | console.log('on addGroup', res); 73 | if (res.code) { 74 | return Vue.prototype.$message.error(res.msg); 75 | } 76 | Vue.prototype.$message.success(res.msg); 77 | commit(SET_GROUP_GATHER, res.data); 78 | commit(`app/${SET_LOADING}`, false, { root: true }); 79 | }); 80 | 81 | // 加入群组 82 | socket.on('joinGroup', async (res: ServerRes) => { 83 | if (res.code) { 84 | return Vue.prototype.$message.error(res.msg); 85 | } 86 | console.log('on joinGroup', res); 87 | const { invited, group, userId } = res.data; 88 | 89 | // 此处区分是搜索群加入群聊还是被邀请加入群聊 90 | if (invited) { 91 | // 被邀请的用户Id 92 | const { friendIds } = res.data; 93 | // 当前用户被邀请加入群,则加入群 94 | if (friendIds.includes(user.userId) && !state.groupGather[group.groupId]) { 95 | // commit(SET_GROUP_GATHER, group); 96 | // 获取群里面所有用户的用户信息 97 | socket.emit('chatData', token); 98 | } else if (userId === user.userId) { 99 | // 邀请发起者 100 | commit(ADD_GROUP_MEMBER, { 101 | groupId: group.groupId, 102 | members: Object.values(state.friendGather).filter((friend) => friendIds.includes(friend.userId)), 103 | }); 104 | const groupGather2 = state.groupGather; 105 | // ?? 待优化 106 | commit(SET_ACTIVE_ROOM, groupGather2[group.groupId]); 107 | return Vue.prototype.$message.info(res.msg); 108 | } 109 | } else { 110 | const newUser = res.data.user as Friend; 111 | newUser.online = 1; 112 | // 新用户加入群 113 | if (newUser.userId !== rootState.app.user.userId) { 114 | commit(ADD_GROUP_MEMBER, { 115 | groupId: group.groupId, 116 | members: [newUser], 117 | }); 118 | return Vue.prototype.$message.info(`${newUser.username}加入群${group.groupName}`); 119 | } 120 | // 是用户自己 则加入到某个群 121 | if (!state.groupGather[group.groupId]) { 122 | commit(SET_GROUP_GATHER, group); 123 | // 获取群里面所有用户的用户信息 124 | socket.emit('chatData', token); 125 | } 126 | Vue.prototype.$message.info(`成功加入群${group.groupName}`); 127 | commit(SET_ACTIVE_ROOM, state.groupGather[group.groupId]); 128 | commit(`app/${SET_LOADING}`, false, { root: true }); 129 | } 130 | }); 131 | // 132 | socket.on('joinGroupSocket', (res: ServerRes) => { 133 | console.log('on joinGroupSocket', res); 134 | if (res.code) { 135 | return Vue.prototype.$message.error(res.msg); 136 | } 137 | const newUser: Friend = res.data.user; 138 | newUser.online = 1; 139 | const { group } = res.data; 140 | const groupObj = state.groupGather[group.groupId]; 141 | // 新用户注册后默认进入到DEFAULT_GROUP,此处需要判断一下是否在群内,不在群内的话需要加入本群中 142 | // 否则在线的用户无法收到新成员进群的变更 143 | if (!groupObj.members!.find((member) => member.userId === newUser.userId)) { 144 | newUser.isManager = 0; 145 | groupObj.members!.push(newUser); 146 | Vue.prototype.$message.info(res.msg); 147 | } 148 | commit(SET_USER_GATHER, newUser); 149 | }); 150 | 151 | socket.on('groupMessage', (res: ServerRes) => { 152 | console.log('on groupMessage', res); 153 | if (!res.code) { 154 | commit(ADD_GROUP_MESSAGE, res.data); 155 | const { activeRoom } = state; 156 | if (activeRoom && activeRoom.groupId !== res.data.groupId) { 157 | commit(ADD_UNREAD_GATHER, res.data.groupId); 158 | } 159 | } else { 160 | Vue.prototype.$message.error(res.msg); 161 | } 162 | }); 163 | 164 | socket.on('addFriend', (res: ServerRes) => { 165 | console.log('on addFriend', res); 166 | if (!res.code) { 167 | commit(SET_FRIEND_GATHER, res.data); 168 | commit(SET_USER_GATHER, res.data); 169 | // 取消loading 170 | Vue.prototype.$message.info(res.msg); 171 | socket.emit('joinFriendSocket', { 172 | userId: user.userId, 173 | friendId: res.data.userId, 174 | }); 175 | } else { 176 | Vue.prototype.$message.error(res.msg); 177 | } 178 | commit(`app/${SET_LOADING}`, false, { root: true }); 179 | }); 180 | 181 | socket.on('joinFriendSocket', (res: ServerRes) => { 182 | console.log('on joinFriendSocket', res); 183 | // 添加好友之后默认进入好友聊天房间,初始化时不默认选中该好友房间 184 | if (!state.activeRoom) { 185 | commit(SET_ACTIVE_ROOM, state.friendGather[res.data.friendId]); 186 | } 187 | if (!res.code) { 188 | console.log('成功加入私聊房间'); 189 | } 190 | }); 191 | 192 | socket.on('friendMessage', async (res: ServerRes) => { 193 | console.log('on friendMessage', res); 194 | if (!res.code) { 195 | if (res.data.friendId === user.userId || res.data.userId === user.userId) { 196 | console.log('ADD_FRIEND_MESSAGE', res.data); 197 | commit(ADD_FRIEND_MESSAGE, res.data); 198 | // 新增私聊信息需要检测本地是否已删除聊天,如已删除需要恢复 199 | let deletedChat = (await localforage.getItem(`${user.userId}-deletedChatId`)) as string[]; 200 | if (deletedChat) { 201 | if (res.data.friendId === user.userId) { 202 | deletedChat = deletedChat.filter((id) => id !== res.data.userId); 203 | } else { 204 | deletedChat = deletedChat.filter((id) => id !== res.data.friendId); 205 | } 206 | await localforage.setItem(`${user.userId}-deletedChatId`, deletedChat); 207 | } 208 | const { activeRoom } = state; 209 | if (activeRoom && activeRoom.userId !== res.data.userId && activeRoom.userId !== res.data.friendId) { 210 | commit(ADD_UNREAD_GATHER, res.data.userId); 211 | } 212 | } 213 | } else { 214 | Vue.prototype.$message.error(res.msg); 215 | } 216 | }); 217 | 218 | socket.on('chatData', (res: ServerRes) => { 219 | if (res.code) { 220 | return Vue.prototype.$message.error(res.msg); 221 | } 222 | console.log(res); 223 | dispatch('handleChatData', res.data); 224 | commit(SET_DROPPED, false); 225 | }); 226 | 227 | // 退出群组 228 | socket.on('exitGroup', (res: ServerRes) => { 229 | if (!res.code) { 230 | // 如果是当前用户退群,则删除群聊 231 | if (res.data.userId === user.userId) { 232 | commit(DEL_GROUP, res.data); 233 | commit(SET_ACTIVE_ROOM, state.groupGather[DEFAULT_GROUP]); 234 | Vue.prototype.$message.success(res.msg); 235 | } else { 236 | console.log(`--用户--${res.data.userId}`, '--退出群--', res.data.groupId); 237 | // 广播给其他用户,从群成员中删除该成员 238 | commit(DEL_GROUP_MEMBER, res.data); 239 | } 240 | } else if (res.data.userId === user.userId) { 241 | Vue.prototype.$message.error(res.msg); 242 | } 243 | }); 244 | 245 | // 更新群信息 246 | socket.on('updateGroupInfo', (res: ServerRes) => { 247 | if (!res.code) { 248 | const group = state.groupGather[res.data.groupId]; 249 | if (group) { 250 | group.groupName = res.data.groupName; 251 | group.notice = res.data.notice; 252 | if (state.activeRoom!.groupId) { 253 | state.activeRoom!.groupName = res.data.groupName; 254 | state.activeRoom!.notice = res.data.notice; 255 | } 256 | if (res.data.userId === user.userId) { 257 | Vue.prototype.$message.success(res.msg); 258 | } 259 | } 260 | } 261 | }); 262 | 263 | // 更新好友信息 264 | socket.on('updateUserInfo', (res: ServerRes) => { 265 | if (!res.code) { 266 | commit(UPDATE_USER_INFO, res.data); 267 | } 268 | }); 269 | // 删除好友 270 | socket.on('exitFriend', (res: ServerRes) => { 271 | if (!res.code) { 272 | commit(DEL_FRIEND, res.data); 273 | commit(SET_ACTIVE_ROOM, state.groupGather[DEFAULT_GROUP]); 274 | Vue.prototype.$message.success(res.msg); 275 | } else { 276 | Vue.prototype.$message.error(res.msg); 277 | } 278 | }); 279 | 280 | // 消息撤回 281 | socket.on('revokeMessage', (res: ServerRes) => { 282 | if (!res.code) { 283 | commit(REVOKE_MESSAGE, res.data); 284 | } else { 285 | Vue.prototype.$message.error(res.msg); 286 | } 287 | }); 288 | }, 289 | 290 | // 根据chatData返回的好友列表群组列表 291 | // 建立各自socket连接 292 | // 并保存至各自Gather 293 | async handleChatData({ commit, dispatch, state, rootState }, payload) { 294 | const { user } = rootState.app; 295 | const { socket } = state; 296 | const { groupGather } = state; 297 | const groupArr = payload.groupData; 298 | const friendArr = payload.friendData; 299 | const userArr = payload.userData; 300 | if (groupArr.length) { 301 | // eslint-disable-next-line no-restricted-syntax 302 | for (const group of groupArr) { 303 | socket.emit('joinGroupSocket', { 304 | groupId: group.groupId, 305 | userId: user.userId, 306 | }); 307 | commit(SET_GROUP_GATHER, group); 308 | } 309 | } 310 | if (friendArr.length) { 311 | // eslint-disable-next-line no-restricted-syntax 312 | for (const friend of friendArr) { 313 | socket.emit('joinFriendSocket', { 314 | userId: user.userId, 315 | friendId: friend.userId, 316 | }); 317 | commit(SET_FRIEND_GATHER, friend); 318 | } 319 | } 320 | if (userArr.length) { 321 | // eslint-disable-next-line no-restricted-syntax 322 | for (const user_ of userArr) { 323 | commit(SET_USER_GATHER, user_); 324 | } 325 | } 326 | 327 | /** 328 | * 由于groupgather和userGather都更新了, 但是activeGather依旧是老对象, 329 | * 这里需要根据老的activeGather找到最新的gather对象,这样才能使得vue的watch监听新gather 330 | */ 331 | 332 | const { activeRoom } = state; 333 | console.log('init'); 334 | console.log(activeRoom); 335 | const groupGather2 = state.groupGather; 336 | const friendGather2 = state.friendGather; 337 | if (!activeRoom) { 338 | console.log(DEFAULT_GROUP); 339 | // 更新完数据没有默认activeRoom设置群为DEFAULT_GROUP 340 | return commit(SET_ACTIVE_ROOM, groupGather[DEFAULT_GROUP]); 341 | } 342 | commit(SET_ACTIVE_ROOM, groupGather2[activeRoom.groupId] || friendGather2[activeRoom.userId]); 343 | }, 344 | }; 345 | 346 | export default actions; 347 | -------------------------------------------------------------------------------- /client/src/store/modules/chat/getters.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree } from 'vuex'; 2 | import { ChatState } from './state'; 3 | import { RootState } from '../../index'; 4 | 5 | const getters: GetterTree = { 6 | socket(state) { 7 | return state.socket; 8 | }, 9 | dropped(state) { 10 | return state.dropped; 11 | }, 12 | activeRoom(state) { 13 | return state.activeRoom; 14 | }, 15 | groupGather(state) { 16 | return state.groupGather; 17 | }, 18 | friendGather(state) { 19 | return state.friendGather; 20 | }, 21 | userGather(state) { 22 | return state.userGather; 23 | }, 24 | unReadGather(state) { 25 | return state.unReadGather; 26 | }, 27 | }; 28 | 29 | export default getters; 30 | -------------------------------------------------------------------------------- /client/src/store/modules/chat/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import actions from './actions'; 3 | import mutations from './mutations'; 4 | import getters from './getters'; 5 | import state, { ChatState } from './state'; 6 | import { RootState } from '../../index'; 7 | 8 | const chat: Module = { 9 | namespaced: true, 10 | state, 11 | mutations, 12 | actions, 13 | getters, 14 | }; 15 | 16 | export default chat; 17 | -------------------------------------------------------------------------------- /client/src/store/modules/chat/mutation-types.ts: -------------------------------------------------------------------------------- 1 | export const SET_SOCKET = 'set_socket'; 2 | export const SET_DROPPED = 'set_dropped'; 3 | export const SET_ACTIVE_GROUP_USER = 'set_active_group_user'; 4 | export const SET_ACTIVE_ROOM = 'set_active_room'; 5 | export const SET_USER_GATHER = 'set_user_gather'; 6 | export const SET_FRIEND_GATHER = 'set_friend_gather'; 7 | export const SET_GROUP_GATHER = 'set_group_gather'; 8 | export const ADD_GROUP_MESSAGE = 'add_group_message'; 9 | export const SET_GROUP_MESSAGES = 'set_group_messages'; 10 | export const ADD_FRIEND_MESSAGE = 'add_friend_message'; 11 | export const SET_FRIEND_MESSAGES = 'set_friend_messages'; 12 | export const DEL_GROUP = 'del_group'; 13 | export const DEL_GROUP_MEMBER = 'del_group_member'; 14 | export const DEL_FRIEND = 'del_friend'; 15 | export const ADD_UNREAD_GATHER = 'set_unread_gather'; 16 | export const LOSE_UNREAD_GATHER = 'lose_unread_gather'; 17 | export const REVOKE_MESSAGE = 'revoke_message'; 18 | export const USER_ONLINE = 'user_online'; 19 | export const USER_OFFLINE = 'user_offline'; 20 | export const ADD_GROUP_MEMBER = 'add_group_member'; 21 | export const UPDATE_USER_INFO = 'update_user_info'; 22 | -------------------------------------------------------------------------------- /client/src/store/modules/chat/mutations.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { MutationTree } from 'vuex'; 3 | import { 4 | SET_SOCKET, 5 | SET_DROPPED, 6 | ADD_GROUP_MEMBER, 7 | ADD_GROUP_MESSAGE, 8 | SET_GROUP_MESSAGES, 9 | ADD_FRIEND_MESSAGE, 10 | SET_FRIEND_MESSAGES, 11 | SET_ACTIVE_ROOM, 12 | SET_GROUP_GATHER, 13 | SET_FRIEND_GATHER, 14 | SET_USER_GATHER, 15 | DEL_GROUP, 16 | DEL_GROUP_MEMBER, 17 | DEL_FRIEND, 18 | ADD_UNREAD_GATHER, 19 | LOSE_UNREAD_GATHER, 20 | REVOKE_MESSAGE, 21 | USER_ONLINE, 22 | USER_OFFLINE, 23 | UPDATE_USER_INFO, 24 | } from './mutation-types'; 25 | import { ChatState } from './state'; 26 | 27 | const mutations: MutationTree = { 28 | // 保存socket 29 | [SET_SOCKET](state, payload: SocketIOClient.Socket) { 30 | state.socket = payload; 31 | }, 32 | 33 | // 设置用户是否处于掉线重连状态 34 | [SET_DROPPED](state, payload: boolean) { 35 | state.dropped = payload; 36 | }, 37 | 38 | /** 39 | * 用户上线 40 | * @param state 41 | * @param payload userId 42 | */ 43 | [USER_ONLINE](state, userId: string) { 44 | // 更新好友列表用户状态 45 | if (state.friendGather[userId]) { 46 | console.log(`${userId}----上线`); 47 | Vue.set(state.friendGather[userId], 'online', 1); 48 | console.log(state.friendGather); 49 | } 50 | // 更新所有群组中该成员在线状态 51 | (Object.values(state.groupGather) as Group[]).forEach((group) => { 52 | const member = group.members!.find((m) => m.userId === userId); 53 | if (member) { 54 | member.online = 1; 55 | } 56 | }); 57 | }, 58 | 59 | // 用户下线 60 | [USER_OFFLINE](state, userId: string) { 61 | if (state.friendGather[userId]) { 62 | Vue.set(state.friendGather[userId], 'online', 0); 63 | } 64 | // 更新所有群组中该成员在线状态 65 | (Object.values(state.groupGather) as Group[]).forEach((group) => { 66 | const member = group.members!.find((m) => m.userId === userId); 67 | if (member) { 68 | member.online = 0; 69 | } 70 | }); 71 | }, 72 | // 新增群成员 73 | [ADD_GROUP_MEMBER]( 74 | state, 75 | payload: { 76 | groupId: string; 77 | members: Friend[]; 78 | } 79 | ) { 80 | const members: Friend[] = payload.members.map((member) => ({ 81 | ...member, 82 | isManager: 0, 83 | })); 84 | if (state.groupGather[payload.groupId].members && members) { 85 | state.groupGather[payload.groupId].members = state.groupGather[payload.groupId].members!.concat(members); 86 | } else { 87 | // vuex对象数组中对象改变不更新问题 88 | Vue.set(state.groupGather[payload.groupId], 'members', members); 89 | } 90 | }, 91 | // 新增一条群消息 92 | [ADD_GROUP_MESSAGE](state, payload: GroupMessage) { 93 | if (state.groupGather[payload.groupId].messages) { 94 | state.groupGather[payload.groupId].messages!.push(payload); 95 | } else { 96 | // vuex对象数组中对象改变不更新问题 97 | Vue.set(state.groupGather[payload.groupId], 'messages', [payload]); 98 | } 99 | }, 100 | 101 | // 设置群消息 102 | [SET_GROUP_MESSAGES](state, payload: GroupMessage[]) { 103 | if (payload && payload.length) { 104 | Vue.set(state.groupGather[payload[0].groupId], 'messages', payload); 105 | } 106 | }, 107 | 108 | // 新增一条私聊消息 109 | [ADD_FRIEND_MESSAGE](state, payload: FriendMessage) { 110 | // @ts-ignore 111 | const { userId } = this.getters['app/user']; 112 | if (payload.friendId === userId) { 113 | if (state.friendGather[payload.userId].messages) { 114 | state.friendGather[payload.userId].messages!.push(payload); 115 | } else { 116 | Vue.set(state.friendGather[payload.userId], 'messages', [payload]); 117 | } 118 | } else if (state.friendGather[payload.friendId].messages) { 119 | state.friendGather[payload.friendId].messages!.push(payload); 120 | } else { 121 | Vue.set(state.friendGather[payload.friendId], 'messages', [payload]); 122 | } 123 | }, 124 | 125 | // 设置私聊记录 126 | [SET_FRIEND_MESSAGES](state, payload: FriendMessage[]) { 127 | // @ts-ignore 128 | const { userId } = this.getters['app/user']; 129 | if (payload && payload.length) { 130 | if (payload[0].friendId === userId) { 131 | Vue.set(state.friendGather[payload[0].userId], 'messages', payload); 132 | } else { 133 | Vue.set(state.friendGather[payload[0].friendId], 'messages', payload); 134 | } 135 | } 136 | }, 137 | 138 | // 设置当前聊天对象(群或好友) 139 | [SET_ACTIVE_ROOM](state, payload: Friend & Group) { 140 | state.activeRoom = payload; 141 | }, 142 | 143 | // 设置所有的群的群详细信息(头像,群名字等) 144 | [SET_GROUP_GATHER](state, payload: Group) { 145 | Vue.set(state.groupGather, payload.groupId, payload); 146 | }, 147 | 148 | // 设置所有的用户的用户详细信息(头像,昵称等) 149 | [SET_USER_GATHER](state, payload: User) { 150 | Vue.set(state.userGather, payload.userId, payload); 151 | }, 152 | 153 | // 设置所有的好友的用户详细信息(头像,昵称等) 154 | [SET_FRIEND_GATHER](state, payload: User) { 155 | Vue.set(state.friendGather, payload.userId, payload); 156 | }, 157 | 158 | // 设置所有的用户的用户详细信息(头像,昵称等) 159 | [UPDATE_USER_INFO](state, user: User) { 160 | const { userId, username, avatar } = user; 161 | const { userGather, friendGather } = state; 162 | if (userGather[userId]) { 163 | userGather[userId].username = username; 164 | userGather[userId].avatar = avatar; 165 | } 166 | if (friendGather[userId]) { 167 | friendGather[userId].username = username; 168 | friendGather[userId].avatar = avatar; 169 | } 170 | }, 171 | 172 | // 退群 173 | [DEL_GROUP](state, payload: GroupMap) { 174 | Vue.delete(state.groupGather, payload.groupId); 175 | }, 176 | 177 | // 删除群成员 178 | [DEL_GROUP_MEMBER](state, payload: GroupMap) { 179 | const group = state.groupGather[payload.groupId]; 180 | if (group) { 181 | group.members = group.members!.filter((member) => member.userId !== payload.userId); 182 | } 183 | }, 184 | 185 | // 删好友 186 | [DEL_FRIEND](state, payload: UserMap) { 187 | Vue.delete(state.friendGather, payload.friendId); 188 | }, 189 | 190 | // 给某个聊天组添加未读消息 191 | [ADD_UNREAD_GATHER](state, payload: string) { 192 | document.title = '【有未读消息】TylooChat聊天室'; 193 | if (!state.unReadGather[payload]) { 194 | Vue.set(state.unReadGather, payload, 1); 195 | } else { 196 | ++state.unReadGather[payload]; 197 | } 198 | }, 199 | 200 | // 给某个聊天组清空未读消息 201 | [LOSE_UNREAD_GATHER](state, payload: string) { 202 | document.title = 'TylooChat聊天室'; 203 | Vue.set(state.unReadGather, payload, 0); 204 | }, 205 | 206 | // 消息撤回 207 | [REVOKE_MESSAGE](state, payload: FriendMessage & GroupMessage & { username: string }) { 208 | // @ts-ignore 209 | const { userId } = this.getters['app/user']; 210 | // 撤回的为群消息 211 | if (payload.groupId) { 212 | const { messages } = state.groupGather[payload.groupId]; 213 | // 将该消息设置为isRevoke,并设置撤回人姓名 214 | if (messages) { 215 | const msg = messages.find((message) => message._id === payload._id); 216 | if (msg) { 217 | Vue.set(msg, 'isRevoke', true); 218 | Vue.set(msg, 'revokeUserName', payload.username); 219 | } 220 | } 221 | } else { 222 | const { messages } = state.friendGather[payload.friendId === userId ? payload.userId : payload.friendId]; 223 | // 将该消息设置为isRevoke,并设置撤回人姓名 224 | if (messages) { 225 | const msg = messages.find((message) => message._id === payload._id); 226 | if (msg) { 227 | Vue.set(msg, 'isRevoke', true); 228 | Vue.set(msg, 'revokeUserName', payload.username); 229 | } 230 | } 231 | } 232 | }, 233 | }; 234 | 235 | export default mutations; 236 | -------------------------------------------------------------------------------- /client/src/store/modules/chat/state.ts: -------------------------------------------------------------------------------- 1 | export interface ChatState { 2 | socket: SocketIOClient.Socket; 3 | dropped: boolean; 4 | activeRoom: (Group & Friend) | null; 5 | groupGather: GroupGather; 6 | userGather: FriendGather; 7 | friendGather: FriendGather; 8 | unReadGather: UnReadGather; 9 | } 10 | 11 | const chatState: ChatState = { 12 | // @ts-ignore 13 | socket: null, // ws实例 14 | dropped: false, // 是否断开连接 15 | activeRoom: null, // 当前访问房间 16 | groupGather: {}, // 群组列表 17 | userGather: {}, // 设置群在线用户列表 18 | friendGather: {}, // 好友列表 19 | unReadGather: {}, // 所有会话未读消息集合 20 | }; 21 | 22 | export default chatState; 23 | -------------------------------------------------------------------------------- /client/src/styles/coverAntd.scss: -------------------------------------------------------------------------------- 1 | // 修改antd tab默认样式 2 | .ant-tabs-bar { 3 | margin: 0 0 10px 0 !important; 4 | } 5 | 6 | // 抽屉组件重写样式 7 | .ant-drawer-content-wrapper { 8 | background-color: #f8f8f8 !important; 9 | .ant-drawer-content { 10 | background-color: #f8f8f8 !important; 11 | .ant-drawer-body { 12 | padding: 0 !important; 13 | // 移动端群组部分 14 | .room-card { 15 | background-color: rgba(115, 165, 200, 0.2); 16 | } 17 | .room-card.active { 18 | background-color: rgba(115, 165, 200, 0.35); 19 | } 20 | .room-card-name { 21 | color: #080808; 22 | } 23 | .text { 24 | color: #080808; 25 | } 26 | 27 | // 在线人数部分 28 | .active-content { 29 | height: 100%; 30 | overflow-y: scroll; 31 | border-radius: 0; 32 | text-align: left; 33 | padding: 10px; 34 | position: relative; 35 | .active-content-title { 36 | text-align: left; 37 | height: 100px; 38 | line-height: 20px; 39 | padding: 0 12px; 40 | font-size: 14px; 41 | color: #080808; 42 | border-bottom: 1px solid #d9d9d9 !important; 43 | .active-content-title-label { 44 | font-size: 12px; 45 | color: #888; 46 | } 47 | .active-content-title-detail { 48 | display: inline-block; 49 | width: 90%; 50 | vertical-align: middle; 51 | } 52 | & > div,span { 53 | overflow: hidden; //超出的文本隐藏 54 | text-overflow: ellipsis; //溢出用省略号显示 55 | white-space: nowrap; //溢出不换行 56 | } 57 | } 58 | .active-content-sum { 59 | // font-weight: bold; 60 | height: 40px; 61 | line-height: 40px; 62 | padding: 0 12px; 63 | color: #080808; 64 | } 65 | .active-content-adduser{ 66 | padding: 0 14px; 67 | line-height: 40px; 68 | height: 40px; 69 | display: flex; 70 | color: #080808; 71 | align-items: center; 72 | .icon{ 73 | font-size: 32px; 74 | margin-right: 15px; 75 | } 76 | .label{ 77 | font-size: 16px; 78 | } 79 | } 80 | .active-content-users { 81 | max-height: calc(100% - 220px); 82 | overflow-y: scroll; 83 | .active-content-user { 84 | width: 260px; 85 | overflow: hidden; //超出的文本隐藏 86 | text-overflow: ellipsis; //溢出用省略号显示 87 | white-space: nowrap; //溢出不换行 88 | text-align: left; 89 | height: 40px; 90 | line-height: 40px; 91 | color: #080808; 92 | padding: 0 12px; 93 | position: relative; 94 | font-size: 16px; 95 | .icon{ 96 | font-size: 16px; 97 | color: #080808; 98 | position: absolute; 99 | top: 9px; 100 | right: 35px; 101 | } 102 | } 103 | } 104 | .active-content-out { 105 | position: absolute; 106 | bottom: 0; 107 | font-size: 18px; 108 | left: 0; 109 | height: 50px; 110 | color: #e23b38; 111 | background: #f8f8f8; 112 | border-top: 1px solid #e1e1e1; 113 | border-left:none; 114 | border-right:none; 115 | border-bottom:none; 116 | border-radius: 0 !important; 117 | box-shadow: none !important; 118 | width: 100%; 119 | right: 0; 120 | } 121 | } 122 | } 123 | } 124 | } 125 | .ant-drawer-mask { 126 | background: transparent !important; 127 | } 128 | 129 | // antd drawer 130 | .ant-drawer .ant-drawer-content { 131 | overflow: hidden; 132 | } 133 | .ant-drawer-body { 134 | height: 100%; 135 | overflow: hidden; 136 | } 137 | .chat-drawer { 138 | height: 100%; 139 | overflow: hidden; 140 | } 141 | 142 | // 修改antd collpase默认样式 143 | .ant-collapse { 144 | background: #fbfbfb !important; 145 | border: none; 146 | } 147 | .ant-collapse-item { 148 | border: none !important; 149 | } 150 | .ant-collapse-content { 151 | border: none !important; 152 | } 153 | .ant-collapse-header { 154 | text-align: left; 155 | } 156 | .ant-collapse-content-box { 157 | padding: 0 !important; 158 | } 159 | // 修改antd ant-tree-title 默认样式 160 | .ant-tree-title, 161 | .ant-tree-switcher-line-icon { 162 | color: #080808 !important; 163 | } 164 | 165 | // 上传附件字体修改 166 | .ant-upload { 167 | font-size: inherit!important; 168 | } 169 | 170 | // disabled tree节点默认隐藏 171 | .ant-tree-treenode-switcher-open .ant-tree-checkbox-disabled,.ant-tree-switcher-noop{ 172 | // display: none!important; 173 | } -------------------------------------------------------------------------------- /client/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './coverAntd.scss'; 2 | 3 | .message { 4 | // 滚动条样式 5 | ::-webkit-scrollbar-track-piece { 6 | background-color: rgba(54, 50, 50, 0.4); 7 | } 8 | ::-webkit-scrollbar { 9 | width: 5px; 10 | height: 10px; 11 | display: block; 12 | } 13 | ::-webkit-scrollbar-thumb { 14 | height: 50px; 15 | background-color: rgba(9, 185, 85, 0.3); 16 | outline-offset: -2px; 17 | filter: alpha(opacity = 50); 18 | -moz-opacity: 0.5; 19 | opacity: 0.5; 20 | border-radius: 4px; 21 | } 22 | ::-webkit-scrollbar-thumb:hover { 23 | background-color: rgba(9, 185, 85, 0.5); 24 | } 25 | } 26 | ::-webkit-scrollbar { 27 | display: none; 28 | } 29 | 30 | // * { 31 | // // 禁止文字被鼠标选中 32 | // moz-user-select: -moz-none; 33 | // -moz-user-select: none; 34 | // -o-user-select:none; 35 | // -webkit-user-select:none; 36 | // -ms-user-select:none; 37 | // user-select:none; 38 | // } 39 | 40 | .viewer-button { 41 | background-color: rgba(231, 226, 226, 0.6)!important; 42 | } 43 | -------------------------------------------------------------------------------- /client/src/styles/theme.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: 主题配色 3 | * @author: BoBo 4 | * @copyright: BoBo 5 | * @Date: 2020-11-26 14:21:25 6 | */ 7 | 8 | // 主题色 9 | $primary-color: #09b955; 10 | $room-bg-color: #fbfbfb; 11 | $message-bg-color:#f1f1f1; -------------------------------------------------------------------------------- /client/src/types/chat.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'socket.io-client'; 2 | 3 | // 所有群的群信息 4 | interface GroupGather { 5 | [groupId: string]: Group; 6 | } 7 | 8 | // 群组 9 | interface Group { 10 | groupId: string; 11 | userId: string; // 群主id 12 | groupName: string; 13 | notice: string; 14 | messages?: GroupMessage[]; 15 | createTime: number; 16 | isTop?: boolean; // 是否置顶聊天 17 | members?: Friend[]; // 群成员列表 18 | } 19 | 20 | // 群与用户关联表 21 | interface GroupMap { 22 | groupId: string; 23 | userId: string; 24 | } 25 | 26 | // 群消息 27 | interface GroupMessage { 28 | _id?: number; 29 | userId: string; 30 | groupId: string; 31 | content: string; 32 | messageType: MessageType; 33 | time: number; 34 | isRevoke?: boolean; // 是否已撤回 35 | revokeUserName?: string; // 撤回人姓名 36 | } 37 | 38 | // 所有好友的好友信息 39 | interface FriendGather { 40 | [userId: string]: Friend; 41 | } 42 | 43 | // 好友 44 | interface Friend { 45 | userId: string; 46 | username: string; 47 | avatar?: string; 48 | role?: string; 49 | tag?: string; 50 | messages?: FriendMessage[]; 51 | createTime?: number; 52 | isTop?: boolean; // 是否置顶聊天 53 | online?: 1 | 0; // 是否在线 54 | isManager?: 1 | 0; // 是否为群主 55 | } 56 | 57 | // 用户与好友关联表 58 | interface UserMap { 59 | friendId: string; 60 | userId: string; 61 | } 62 | 63 | // 好友消息 64 | interface FriendMessage { 65 | _id?: number; 66 | userId: string; 67 | friendId: string; 68 | content: string; 69 | messageType: MessageType; 70 | time: number; 71 | type?: string; 72 | isRevoke?: boolean; // 是否已撤回 73 | revokeUserName?: string; // 撤回人姓名 74 | } 75 | 76 | interface SendMessage { 77 | type: string; 78 | message: string | File; 79 | width?: number; 80 | height?: number; 81 | fileName?: string; // 上传附件名 82 | messageType: MessageType[0] | MessageType[1]; 83 | size?: number; // 附件大小 84 | } 85 | 86 | // 消息类型 87 | declare enum MessageType { 88 | text = 'text', 89 | image = 'image', 90 | file = 'file', 91 | video = 'video', 92 | } 93 | 94 | // 图片尺寸 95 | interface ImageSize { 96 | width: number; 97 | height: number; 98 | } 99 | 100 | // 服务端返回值格式 101 | interface ServerRes { 102 | code: number; 103 | msg: string; 104 | data: any; 105 | } 106 | 107 | // 未读消息对象 108 | interface UnReadGather { 109 | [key: string]: number; 110 | } 111 | 112 | // 获取群分页消息参数 113 | interface PagingParams { 114 | groupId?: string; 115 | userId?: string; 116 | friendId?: string; 117 | current: number; 118 | pageSize: number; 119 | } 120 | 121 | // 群分页消息返回值 122 | interface PagingResponse { 123 | messageArr: GroupMessage[]; 124 | userArr: User[]; 125 | } 126 | 127 | interface FriendMap { 128 | friendId: string; 129 | friendUserName: string; 130 | } 131 | 132 | // 右键菜单操作烈性 133 | declare enum ContextMenuType { 134 | COPY = 'COPY', // 复制 135 | REVOKE = 'REVOKE', // 撤回 136 | TOP_REVERT = 'TOP_REVERT', // 取消置顶 137 | TOP = 'TOP', // 置顶 138 | READ = 'READ', // 一键已读 139 | DELETE = 'DELETE', // 删除 140 | } 141 | -------------------------------------------------------------------------------- /client/src/types/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/types/shims-vue-expand.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import VueRouter, { Route } from 'vue-router'; 3 | import { Store } from 'vuex'; 4 | import * as lodash from 'lodash'; 5 | 6 | // 扩充 7 | declare module 'vue/types/vue' { 8 | interface Vue { 9 | $router: VueRouter; 10 | $localforage: any 11 | $route: Route; 12 | $store: Store; 13 | $lodash: typeof lodash; 14 | } 15 | } -------------------------------------------------------------------------------- /client/src/types/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | 4 | export default Vue; 5 | } 6 | 7 | declare module 'v-contextmenu'; 8 | -------------------------------------------------------------------------------- /client/src/types/user.d.ts: -------------------------------------------------------------------------------- 1 | interface User { 2 | userId: string; 3 | username: string; 4 | password: string; 5 | avatar: string; 6 | role?: string; 7 | tag?: string; 8 | createTime: number; 9 | online?: 1 | 0; // 是否在线 10 | } 11 | -------------------------------------------------------------------------------- /client/src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { AxiosResponse } from 'axios'; 3 | 4 | // 处理所有后端返回的数据 5 | export function processReturn(res: AxiosResponse) { 6 | // code 0:成功 1:错误 2:后端报错 7 | const { code, msg, data } = res.data; 8 | if (code) { 9 | Vue.prototype.$message.error(msg); 10 | return; 11 | } 12 | if (msg) { 13 | Vue.prototype.$message.success(msg); 14 | } 15 | return data; 16 | } 17 | 18 | // 判断一个字符串是否包含另外一个字符串 19 | export function isContainStr(str1: string, str2: string) { 20 | return str2.indexOf(str1) >= 0; 21 | } 22 | 23 | /** 24 | * 屏蔽词 25 | * @param text 文本 26 | */ 27 | export function parseText(text: string) { 28 | return text; 29 | } 30 | 31 | /** 32 | * 判断是否URL 33 | * @param text 文本 34 | */ 35 | export function isUrl(text: string) { 36 | // 解析网址 37 | // eslint-disable-next-line no-useless-escape 38 | const UrlReg = new RegExp(/http(s)?:\/\/([\w-]+\.)+[\w-]+(\/[\w- .\/?%&=]*)?/); 39 | return UrlReg.test(text); 40 | } 41 | 42 | /** 43 | * 消息时间格式化 44 | * @param time 45 | */ 46 | export function formatTime(time: number) { 47 | const moment = Vue.prototype.$moment; 48 | // 大于昨天 49 | if (moment().add(-1, 'days').startOf('day') > time) { 50 | return moment(time).format('M/D HH:mm'); 51 | } 52 | // 昨天 53 | if (moment().startOf('day') > time) { 54 | return `昨天 ${moment(time).format('HH:mm')}`; 55 | } 56 | // 大于五分钟不显示秒 57 | // if (new Date().valueOf() > time + 300000) { 58 | // return moment(time).format('HH:mm'); 59 | // } 60 | return moment(time).format('HH:mm'); 61 | } 62 | 63 | /** 64 | * 群名/用户名校验 65 | * @param name 66 | */ 67 | export function nameVerify(name: string): boolean { 68 | const nameReg = /^(?!_)(?!.*?_$)[a-zA-Z0-9_\u4e00-\u9fa5]+$/; 69 | if (name.length === 0) { 70 | Vue.prototype.$message.error('请输入名字'); 71 | return false; 72 | } 73 | if (!nameReg.test(name)) { 74 | Vue.prototype.$message.error('名字只含有汉字、字母、数字和下划线 不能以下划线开头和结尾'); 75 | return false; 76 | } 77 | if (name.length > 16) { 78 | Vue.prototype.$message.error('名字太长'); 79 | return false; 80 | } 81 | return true; 82 | } 83 | 84 | /** 85 | * 密码校验 86 | * @param password 87 | */ 88 | export function passwordVerify(password: string): boolean { 89 | const passwordReg = /^\w+$/gis; 90 | if (password.length === 0) { 91 | Vue.prototype.$message.error('请输入密码'); 92 | return false; 93 | } 94 | if (!passwordReg.test(password)) { 95 | Vue.prototype.$message.error('密码只含有字母、数字和下划线'); 96 | return false; 97 | } 98 | if (password.length > 16) { 99 | Vue.prototype.$message.error('密码最多16位,请重新输入'); 100 | return false; 101 | } 102 | return true; 103 | } 104 | -------------------------------------------------------------------------------- /client/src/views/Chat.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 204 | 302 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strictPropertyInitialization": false, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | ], 35 | "exclude": [ 36 | "node_modules" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | const isDev = process.env.NODE_ENV === 'development'; 4 | 5 | // cdn链接 6 | const cdn = { 7 | css: [ 8 | // antd css 由于引入失败只好放弃了antd的按需引入 9 | ], 10 | js: [ 11 | // vue 12 | 'https://cdn.bootcdn.net/ajax/libs/vue/2.6.10/vue.min.js', 13 | // vue-router 14 | 'https://cdn.bootcdn.net/ajax/libs/vue-router/3.1.3/vue-router.min.js', 15 | // vuex 16 | 'https://cdn.bootcdn.net/ajax/libs/vuex/3.1.2/vuex.min.js', 17 | // axios 18 | 'https://cdn.bootcdn.net/ajax/libs/axios/0.18.0/axios.min.js', 19 | // moment 20 | 'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.27.0/moment.min.js', 21 | // lodash 22 | 'https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.20/lodash.min.js', 23 | ], 24 | }; 25 | 26 | const CompressionWebpackPlugin = require('compression-webpack-plugin'); 27 | 28 | module.exports = { 29 | chainWebpack: (config) => { 30 | // 需要打包分析时取消注释 31 | // eslint-disable-next-line global-require 32 | // config.plugin('webpack-bundle-analyzer').use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin); 33 | 34 | // 配置cdn引入 35 | if (process.env.NODE_ENV === 'production' && process.env.VUE_APP_CDN === 'true') { 36 | const externals = { 37 | vue: 'Vue', 38 | axios: 'axios', 39 | 'vue-router': 'VueRouter', 40 | vuex: 'Vuex', 41 | moment: 'moment', 42 | lodash: '_', 43 | }; 44 | config.externals(externals); 45 | // 通过 html-webpack-plugin 将 cdn 注入到 index.html 之中 46 | config.plugin('html').tap((args) => { 47 | // eslint-disable-next-line no-param-reassign 48 | args[0].cdn = cdn; 49 | return args; 50 | }); 51 | } 52 | }, 53 | configureWebpack: (config) => { 54 | // 代码 gzip 55 | const productionGzipExtensions = ['html', 'js', 'css']; 56 | // 开发模式下不走gzip 57 | if (!isDev) { 58 | config.plugins.push( 59 | new CompressionWebpackPlugin({ 60 | filename: '[path].gz[query]', 61 | algorithm: 'gzip', 62 | test: new RegExp(`\\.(${productionGzipExtensions.join('|')})$`), 63 | threshold: 10240, // 只有大小大于该值的资源会被处理 10240 64 | minRatio: 0.8, // 只有压缩率小于这个值的资源才会被处理 65 | deleteOriginalAssets: false, // 删除原文件 66 | }) 67 | ); 68 | } 69 | 70 | // 不打包moment的语言包 71 | config.plugins.push(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)); 72 | 73 | // 去除console 74 | if (process.env.NODE_ENV === 'production') { 75 | // eslint-disable-next-line no-param-reassign 76 | config.optimization.minimizer[0].options.terserOptions.compress.drop_console = true; 77 | } 78 | }, 79 | css: { 80 | loaderOptions: { 81 | less: { 82 | lessOptions: { 83 | modifyVars: { 84 | 'primary-color': '#09b955', 85 | // 'link-color': '#1DA57A', 86 | // 'border-radius-base': '2px', 87 | }, 88 | javascriptEnabled: true, 89 | }, 90 | }, 91 | sass: { 92 | prependData: "@import '@/styles/index.scss';", 93 | }, 94 | }, 95 | }, 96 | // webSocket本身不存在跨域问题,所以我们可以利用webSocket来进行非同源之间的通信。 97 | publicPath: './', 98 | devServer: { 99 | port: 1997, 100 | }, 101 | productionSourceMap: false, 102 | }; 103 | -------------------------------------------------------------------------------- /deploy.md: -------------------------------------------------------------------------------- 1 | # 部署说明 2 | - 部署策略采用前后端分离部署,后端采用CORS解决跨域问题 3 | - **请先切换`feature_APIROBOT`分支进行部署** 4 | 5 | ## 前端部署 6 | 1. 修改.env.out环境变量中的`VUE_APP_API_URL`地址为你的服务器地址 7 | ```js 8 | VUE_APP_API_URL=http://xxx.xxx.xxx.xxx:3000 9 | ``` 10 | 2. 构建前端包 11 | ```js 12 | // client 13 | cnpm i 14 | npm run build 15 | ``` 16 | 3. 将 dist 下所有文件放到 nginx 下的 html 文件夹中 (或者是其他http服务器的对应目录下) 17 | 4. nginx conf 18 | ```js 19 | // nginx.conf 20 | http { 21 | #避免mime类型丢失导致css样式无法正常加载 22 | include mime.types; 23 | #nginx开启gzip 24 | #前端文件在build的时候已经配置好压缩,需要再配置一下nginx; 25 | gzip on; 26 | gzip_static on; 27 | gzip_buffers 4 16k; 28 | gzip_comp_level 5; 29 | gzip_types text/plain application/javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg 30 | image/gif image/png; 31 | 32 | #nginx请求级别配置 33 | server { 34 | listen 80; 35 | server_name www.server.com; 36 | location / { 37 | root html; 38 | index index.html index.htm; 39 | add_header Cache-Control public; 40 | } 41 | location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ 42 | { 43 | expires 30d; 44 | } 45 | 46 | location ~ .*\.(js|css)?$ 47 | { 48 | expires 12h; 49 | } 50 | error_page 500 502 503 504 /50x.html; 51 | location = /50x.html { 52 | root html; 53 | } 54 | } 55 | } 56 | ``` 57 | 5. nginx -s reload 58 | 59 | ## 数据库配置 60 | 1. 创建名为 `chat` 的数据库 61 | 2. 配置后端 `app.module.ts` 中的 mysql 账号密码 62 | ```js 63 | // /server/src/app.module.ts 64 | @Module({ 65 | imports: [ 66 | TypeOrmModule.forRoot({ 67 | type: 'mysql', 68 | port: 3306, 69 | username: 'root', // 默认账号 70 | password: '123456', // 默认密码 71 | database: 'chat', 72 | charset: "utf8mb4", 73 | autoLoadEntities: true, 74 | synchronize: true 75 | }), 76 | ], 77 | }) 78 | ``` 79 | 80 | ## 部署后端服务 81 | 82 | **后台服务默认端口号为`3000`有需要自行修改 main.ts文件** 83 | - 方式一(整个项目拷贝至服务器) 84 | 1. 安装 pm2 85 | ```js 86 | npm i pm2 -g 87 | ``` 88 | 2. 生成 dist 文件 89 | ```js 90 | npm i 91 | npm run build 92 | ``` 93 | 3. 使用 pm2 运行 94 | ```js 95 | npm run pm2 96 | ``` 97 | - 方式二(结合ncc打包,便于离线环境下部署) 98 | 1. 本地执行`npm run pkg` 99 | 2. 拷贝`deploy`文件夹至服务器 100 | 3. 服务器上运行 101 | ```js 102 | node deploy/index 或者 pm2 deploy/index 103 | ``` 104 | 105 | ## 其他注意事项 106 | 107 | - 如果在CentOS上部署出现libc.so.6版本过低,考虑是系统环境问题,升级版本 108 | 109 | - 后端服务器注意安全策略中放行`3000`端口 110 | 111 | - 加qq群.. -------------------------------------------------------------------------------- /node_modules/.yarn-integrity: -------------------------------------------------------------------------------- 1 | { 2 | "systemParams": "darwin-arm64-93", 3 | "modulesFolders": [], 4 | "flags": [], 5 | "linkedModules": [], 6 | "topLevelPatterns": [], 7 | "lockfileEntries": {}, 8 | "files": [], 9 | "artifacts": {} 10 | } -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // .eslintrc.js 2 | module.exports = { 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:prettier/recommended' 7 | ], 8 | plugins: ['@typescript-eslint'], 9 | rules: { 10 | // 这条规则是为了防止写class interface的member时,分隔符和prettier产生冲突 11 | '@typescript-eslint/ban-ts-comment': 'off', 12 | '@typescript-eslint/no-var-requires': 'off', 13 | '@typescript-eslint/explicit-module-boundary-types': 'off', 14 | '@typescript-eslint/no-explicit-any': 'off', 15 | '@typescript-eslint/member-delimiter-style': [ 16 | 'error', 17 | { 18 | multiline: { 19 | delimiter: 'none', 20 | requireLast: false 21 | }, 22 | singleline: { 23 | delimiter: 'comma', 24 | requireLast: false 25 | } 26 | } 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /deploy 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # server 2 | 3 | nestjs -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src/modules" 4 | } 5 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tyloo-chat-server", 3 | "version": "2.1.7", 4 | "description": "", 5 | "author": "boboooooo159@gmail.com", 6 | "private": true, 7 | "license": "MIT", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json", 21 | "pm2": "pm2 start --name tyloochat dist/main.js -i max", 22 | "pkg": "npm run build && ncc build dist/main.js -o deploy" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^7.0.0", 26 | "@nestjs/core": "^7.0.0", 27 | "@nestjs/jwt": "^7.1.0", 28 | "@nestjs/passport": "^7.1.0", 29 | "@nestjs/platform-express": "^7.0.0", 30 | "@nestjs/platform-socket.io": "^7.2.0", 31 | "@nestjs/typeorm": "^7.1.0", 32 | "@nestjs/websockets": "^7.2.0", 33 | "axios": "^0.21.0", 34 | "crypto": "^1.0.1", 35 | "mongodb": "^3.5.9", 36 | "mysql": "^2.18.1", 37 | "nodejieba": "^2.4.2", 38 | "passport": "^0.4.1", 39 | "passport-jwt": "^4.0.0", 40 | "passport-local": "^1.0.0", 41 | "reflect-metadata": "^0.1.13", 42 | "rimraf": "^3.0.2", 43 | "rxjs": "^6.5.4", 44 | "typeorm": "^0.2.25", 45 | "uuid": "^8.2.0" 46 | }, 47 | "devDependencies": { 48 | "@nestjs/cli": "^7.0.0", 49 | "@nestjs/schematics": "^7.0.0", 50 | "@nestjs/testing": "^7.0.0", 51 | "@types/express": "^4.17.3", 52 | "@types/jest": "25.2.3", 53 | "@types/node": "^13.9.1", 54 | "@types/socket.io": "^2.1.8", 55 | "@types/supertest": "^2.0.8", 56 | "@typescript-eslint/eslint-plugin": "3.0.2", 57 | "@typescript-eslint/parser": "3.0.2", 58 | "@zeit/ncc": "^0.22.3", 59 | "eslint": "7.1.0", 60 | "eslint-config-prettier": "^6.10.0", 61 | "eslint-plugin-import": "^2.20.1", 62 | "eslint-plugin-prettier": "^3.1.4", 63 | "husky": "^4.3.0", 64 | "jest": "26.0.1", 65 | "lint-staged": "^10.5.1", 66 | "prettier": "^1.19.1", 67 | "supertest": "^4.0.2", 68 | "ts-jest": "26.1.0", 69 | "ts-loader": "^6.2.1", 70 | "ts-node": "^8.6.2", 71 | "tsconfig-paths": "^3.9.0", 72 | "typescript": "^3.7.4" 73 | }, 74 | "husky": { 75 | "hooks": { 76 | "pre-commit": "lint-staged" 77 | } 78 | }, 79 | "lint-staged": { 80 | "src/**/*.{js,ts}": [ 81 | "prettier --write", 82 | "eslint --fix", 83 | "git add" 84 | ] 85 | }, 86 | "jest": { 87 | "moduleFileExtensions": [ 88 | "js", 89 | "json", 90 | "ts" 91 | ], 92 | "rootDir": "src", 93 | "testRegex": ".spec.ts$", 94 | "transform": { 95 | "^.+\\.(t|j)s$": "ts-jest" 96 | }, 97 | "coverageDirectory": "../coverage", 98 | "testEnvironment": "node" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /server/public/avatar/avatar1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar1.png -------------------------------------------------------------------------------- /server/public/avatar/avatar10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar10.png -------------------------------------------------------------------------------- /server/public/avatar/avatar11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar11.png -------------------------------------------------------------------------------- /server/public/avatar/avatar12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar12.png -------------------------------------------------------------------------------- /server/public/avatar/avatar13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar13.png -------------------------------------------------------------------------------- /server/public/avatar/avatar14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar14.png -------------------------------------------------------------------------------- /server/public/avatar/avatar15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar15.png -------------------------------------------------------------------------------- /server/public/avatar/avatar16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar16.png -------------------------------------------------------------------------------- /server/public/avatar/avatar17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar17.png -------------------------------------------------------------------------------- /server/public/avatar/avatar18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar18.png -------------------------------------------------------------------------------- /server/public/avatar/avatar19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar19.png -------------------------------------------------------------------------------- /server/public/avatar/avatar2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar2.png -------------------------------------------------------------------------------- /server/public/avatar/avatar20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar20.png -------------------------------------------------------------------------------- /server/public/avatar/avatar3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar3.png -------------------------------------------------------------------------------- /server/public/avatar/avatar4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar4.png -------------------------------------------------------------------------------- /server/public/avatar/avatar5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar5.png -------------------------------------------------------------------------------- /server/public/avatar/avatar6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar6.png -------------------------------------------------------------------------------- /server/public/avatar/avatar7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar7.png -------------------------------------------------------------------------------- /server/public/avatar/avatar8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar8.png -------------------------------------------------------------------------------- /server/public/avatar/avatar9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/avatar9.png -------------------------------------------------------------------------------- /server/public/avatar/robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoBoooooo/Tyloo-Chat/8314298d62a9e80e8426d08052e3ffdd40055378/server/public/avatar/robot.png -------------------------------------------------------------------------------- /server/public/static/file/info.md: -------------------------------------------------------------------------------- 1 | 聊天附件上传目录 -------------------------------------------------------------------------------- /server/public/static/image/info.md: -------------------------------------------------------------------------------- 1 | 聊天图片上传目录 -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | import { UserModule } from './modules/user/user.module' 4 | import { ChatModule } from './modules/chat/chat.module' 5 | import { FriendModule } from './modules/friend/friend.module' 6 | import { GroupModule } from './modules/group/group.module' 7 | import { AuthModule } from './modules/auth/auth.module' 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmModule.forRoot({ 12 | type: 'mysql', 13 | host: 'localhost', 14 | port: 3306, 15 | username: 'root', 16 | password: '', 17 | database: 'chat', 18 | charset: 'utf8mb4', // 设置chatset编码为utf8mb4 19 | autoLoadEntities: true, 20 | synchronize: true 21 | }), 22 | UserModule, 23 | ChatModule, 24 | FriendModule, 25 | GroupModule, 26 | AuthModule 27 | ] 28 | }) 29 | export class AppModule {} 30 | -------------------------------------------------------------------------------- /server/src/common/constant/global.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: 全局变量定义 3 | * @author: BoBo 4 | * @copyright: BoBo 5 | * @Date: 2020-11-20 17:40:24 6 | */ 7 | 8 | export const defaultPassword = '123456' // 默认用户密码 9 | export const defaultRobot = '小冰机器人' // 机器人名称 10 | export const defaultRobotId = 'robot' // 机器人ID 11 | export const defaultWelcomeMessage = '欢迎使用小冰机器人,有什么能帮您的呢?😃' // 机器人欢迎语 12 | export const defaultGroup = '系统问题反馈群' // 默认用户群组名称 13 | export const defaultGroupId = 'group' // 默认用户群组ID 14 | export const IMAGE_SAVE_PATH = 'public/static/image' // 聊天图片缓存路径 15 | export const FILE_SAVE_PATH = 'public/static/file' // 聊天附件缓存路径 16 | 17 | export const defaultGroupMessageTime = 86400000 // 新用户进群默认可以看历史消息的时限(此处默认为24小时内的消息) 18 | -------------------------------------------------------------------------------- /server/src/common/constant/rcode.ts: -------------------------------------------------------------------------------- 1 | export enum RCode { 2 | OK, 3 | FAIL, 4 | ERROR 5 | } 6 | -------------------------------------------------------------------------------- /server/src/common/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException 6 | } from '@nestjs/common' 7 | 8 | @Catch(HttpException) 9 | export class HttpExceptionFilter implements ExceptionFilter { 10 | catch(exception: HttpException, host: ArgumentsHost) { 11 | const ctx = host.switchToHttp() 12 | const response = ctx.getResponse() 13 | const request = ctx.getRequest() 14 | const status = exception.getStatus() 15 | const exceptionRes: any = exception.getResponse() 16 | const error = exceptionRes.error 17 | let message = exceptionRes.message 18 | 19 | if (status === 401) { 20 | message = '身份过期,请重新登录' 21 | } 22 | response.status(200).json({ 23 | code: status, 24 | timestamp: new Date().toISOString(), 25 | path: request.url, 26 | error, 27 | msg: message 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/src/common/filters/ws-exception.filter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: 全局ws连接异常捕获 3 | * @author: BoBo 4 | * @copyright: BoBo 5 | * @Date: 2020-12-26 13:25:26 6 | */ 7 | 8 | import { Catch, ArgumentsHost } from '@nestjs/common' 9 | import { BaseWsExceptionFilter } from '@nestjs/websockets' 10 | 11 | @Catch() 12 | export class WsExceptionFilter extends BaseWsExceptionFilter { 13 | catch(exception: unknown, host: ArgumentsHost) { 14 | super.catch(exception, host) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/src/common/guards/WsJwtGuard.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: WS JWT鉴权守卫 3 | * @author: BoBo 4 | * @copyright: BoBo 5 | * @Date: 2020-12-26 11:48:56 6 | */ 7 | import { AuthService } from './../../modules/auth/auth.service' 8 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' 9 | import { WsException } from '@nestjs/websockets' 10 | import { Socket } from 'socket.io' 11 | 12 | @Injectable() 13 | export class WsJwtGuard implements CanActivate { 14 | constructor(private authService: AuthService) {} 15 | 16 | async canActivate(context: ExecutionContext): Promise { 17 | let client: Socket 18 | try { 19 | client = context.switchToWs().getClient() 20 | const authToken: string = client.handshake?.query?.token 21 | const user = this.authService.verifyUser(authToken) 22 | return Boolean(user) 23 | } catch (err) { 24 | client.emit('unauthorized', '用户信息校验失败,请重新登录!') 25 | client.disconnect() 26 | throw new WsException(err.message) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/src/common/interceptor/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NestInterceptor, 3 | ExecutionContext, 4 | CallHandler, 5 | Injectable 6 | } from '@nestjs/common' 7 | import { map } from 'rxjs/operators' 8 | import { RCode } from '../constant/rcode' 9 | 10 | @Injectable() 11 | export class ResponseInterceptor implements NestInterceptor { 12 | intercept( 13 | context: ExecutionContext, 14 | next: CallHandler 15 | ): import('rxjs').Observable | Promise> { 16 | return next.handle().pipe( 17 | map(content => { 18 | return { 19 | data: content.data || {}, 20 | code: content.code || RCode.OK, 21 | msg: content.msg || null 22 | } 23 | }) 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/common/middleware/elasticsearch.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: elasticsearch中间件模块,需要先安装es,并启动 3 | * @author: BoBo 4 | * @copyright: BoBo 5 | * @Date: 2020-11-19 14:16:37 6 | */ 7 | 8 | const axios = require('axios') 9 | 10 | const base = 'http://localhost:9200/robot/_doc/_search' 11 | 12 | export const getElasticData = (query: string): Promise => 13 | axios.get(base, { 14 | params: { 15 | source: JSON.stringify({ 16 | query: { 17 | match: { 18 | title: query 19 | } 20 | } 21 | }), 22 | source_content_type: 'application/json' 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /server/src/common/middleware/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | export function logger(req, res, next) { 2 | const { method, path } = req 3 | console.log(`${method} ${path}`) 4 | next() 5 | } 6 | -------------------------------------------------------------------------------- /server/src/common/tool/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 群名/用户名校验 3 | * @param name 4 | */ 5 | export function nameVerify(name: string): boolean { 6 | const nameReg = /^(?!_)(?!.*?_$)[a-zA-Z0-9_\u4e00-\u9fa5]+$/ 7 | if (name.length === 0) { 8 | return false 9 | } 10 | if (!nameReg.test(name)) { 11 | return false 12 | } 13 | if (name.length > 16) { 14 | return false 15 | } 16 | return true 17 | } 18 | 19 | /** 20 | * 获取文件大小 21 | * @param bytes 22 | * @param decimals 23 | */ 24 | export function formatBytes(bytes, decimals = 2) { 25 | if (bytes === 0) return '0 Bytes' 26 | const k = 1024 27 | const dm = decimals < 0 ? 0 : decimals 28 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 29 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 30 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i] 31 | } 32 | 33 | // md5加盐 34 | const crypto = require('crypto') 35 | 36 | // md5加盐处理password 37 | export function md5(str) { 38 | const m = crypto.createHash('md5') 39 | m.update(str, 'utf8') 40 | return m.digest('hex') 41 | } 42 | -------------------------------------------------------------------------------- /server/src/fix.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: 修改socket.io serveClient,避免ncc打包后启动报错 3 | * https://stackoverflow.com/questions/51914186/cannot-find-module-socket-io-client-dist-socket-io-js-when-starting-express-ap 4 | * @author: BoBo 5 | * @copyright: BoBo 6 | * @Date: 2020-11-30 16:04:32 7 | */ 8 | 9 | const fs = require('fs') 10 | const path = require('path') 11 | 12 | module.exports = async () => { 13 | // 如果已经拉取过依赖包 14 | const socket_io_path = `${path.resolve( 15 | __dirname, 16 | '..' 17 | )}/node_modules/socket.io/lib/index.js` 18 | // 破解gojs 19 | if (fs.existsSync(socket_io_path)) { 20 | fs.readFile(socket_io_path, 'utf8', (err1, files) => { 21 | const result = files.replace( 22 | 'this.serveClient(false !== opts.serveClient);', 23 | '// this.serveClient(false !== opts.serveClient);' 24 | ) 25 | fs.writeFile(socket_io_path, result, 'utf8', err2 => console.log(err2)) 26 | }) 27 | Promise.resolve() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core' 2 | import { NestExpressApplication } from '@nestjs/platform-express' 3 | import { AppModule } from './app.module' 4 | import { HttpExceptionFilter } from './common/filters/http-exception.filter' 5 | import { logger } from './common/middleware/logger.middleware' 6 | import { ResponseInterceptor } from './common/interceptor/response.interceptor' 7 | import { join } from 'path' 8 | import { IoAdapter } from '@nestjs/platform-socket.io' 9 | 10 | const fix_socket_io_bug = require('./fix') 11 | 12 | async function bootstrap() { 13 | await fix_socket_io_bug() 14 | 15 | const app = await NestFactory.create(AppModule, { 16 | cors: true 17 | }) 18 | // https://github.com/vercel/ncc/issues/513 19 | // fix ncc打包后提示找不到该依赖问题 20 | app.useWebSocketAdapter(new IoAdapter(app)) 21 | 22 | // 全局中间件 23 | app.use(logger) 24 | 25 | // 全局过滤器 26 | app.useGlobalFilters(new HttpExceptionFilter()) 27 | 28 | // 配置全局拦截器 29 | app.useGlobalInterceptors(new ResponseInterceptor()) 30 | 31 | // 配置静态资源 32 | app.useStaticAssets(join(__dirname, '../public', '/'), { 33 | prefix: '/', 34 | setHeaders: res => { 35 | res.set('Cache-Control', 'max-age=2592000') 36 | } 37 | }) 38 | 39 | await app.listen(3000) 40 | } 41 | bootstrap() 42 | -------------------------------------------------------------------------------- /server/src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @file: 权限登陆模块 3 | * @author: BoBo 4 | * @copyright: BoBo 5 | * @Date: 2020-11-18 09:18:10 6 | */ 7 | import { Body, Controller, Post } from '@nestjs/common' 8 | import { AuthService } from './auth.service' 9 | 10 | @Controller('auth') 11 | export class AuthController { 12 | constructor(private readonly authService: AuthService) {} 13 | 14 | // 登录测试 15 | @Post('/login') 16 | async login(@Body() body) { 17 | return this.authService.login(body) 18 | } 19 | 20 | @Post('/register') 21 | async register(@Body() body) { 22 | return this.authService.register(body) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { FriendMessage } from './../friend/entity/friendMessage.entity' 2 | import { UserMap } from './../friend/entity/friend.entity' 3 | import { Module } from '@nestjs/common' 4 | import { AuthService } from './auth.service' 5 | import { PassportModule } from '@nestjs/passport' 6 | import { JwtModule } from '@nestjs/jwt' 7 | import { AuthController } from './auth.controller' 8 | import { LocalStrategy } from './local.strategy' 9 | import { JwtStrategy } from './jwt.strategy' 10 | import { jwtConstants } from './constants' 11 | import { TypeOrmModule } from '@nestjs/typeorm' 12 | import { User } from '../user/entity/user.entity' 13 | import { GroupMap } from '../group/entity/group.entity' 14 | 15 | @Module({ 16 | imports: [ 17 | TypeOrmModule.forFeature([User, GroupMap, UserMap, FriendMessage]), 18 | JwtModule.register({ 19 | secret: jwtConstants.secret, 20 | signOptions: { expiresIn: '3d' } 21 | }), 22 | PassportModule 23 | ], 24 | controllers: [AuthController], 25 | providers: [AuthService, LocalStrategy, JwtStrategy], 26 | exports: [AuthService] 27 | }) 28 | export class AuthModule {} 29 | -------------------------------------------------------------------------------- /server/src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultPassword, 3 | defaultRobotId, 4 | defaultGroupId, 5 | defaultWelcomeMessage 6 | } from 'src/common/constant/global' 7 | import { FriendMessage } from './../friend/entity/friendMessage.entity' 8 | import { UserMap } from './../friend/entity/friend.entity' 9 | import { Injectable } from '@nestjs/common' 10 | import { JwtService } from '@nestjs/jwt' 11 | import { InjectRepository } from '@nestjs/typeorm' 12 | import { Repository } from 'typeorm' 13 | import { User } from '../user/entity/user.entity' 14 | import { GroupMap } from '../group/entity/group.entity' 15 | import { md5 } from 'src/common/tool/utils' 16 | import { RCode } from 'src/common/constant/rcode' 17 | import * as jwt from 'jsonwebtoken' 18 | import { jwtConstants } from './constants' 19 | @Injectable() 20 | export class AuthService { 21 | constructor( 22 | @InjectRepository(User) 23 | private readonly userRepository: Repository, 24 | @InjectRepository(GroupMap) 25 | private readonly groupUserRepository: Repository, 26 | @InjectRepository(UserMap) 27 | private readonly userMapRepository: Repository, 28 | @InjectRepository(FriendMessage) 29 | private readonly friendMessageRepository: Repository, 30 | private readonly jwtService: JwtService 31 | ) {} 32 | 33 | async login(data: User): Promise { 34 | let user 35 | // 如果之前传userId 表示为单点登录,直接登录 36 | if (data.userId && !data.password) { 37 | user = await this.userRepository.findOne({ userId: data.userId }) 38 | // 如果当前不存在该用户,自动注册,初始密码为 123456 39 | if (!user) { 40 | const res = this.register({ 41 | ...data, 42 | password: defaultPassword 43 | }) 44 | return res 45 | } 46 | } else { 47 | user = await this.userRepository.findOne({ 48 | username: data.username, 49 | password: md5(data.password) 50 | }) 51 | } 52 | if (!user) { 53 | return { code: 1, msg: '用户名或密码错误', data: '' } 54 | } 55 | const payload = { userId: user.userId } 56 | return { 57 | msg: '登录成功', 58 | data: { 59 | user: user, 60 | token: this.jwtService.sign(payload) 61 | } 62 | } 63 | } 64 | 65 | async register(user: User): Promise { 66 | const isHave = await this.userRepository.find({ username: user.username }) 67 | if (isHave.length) { 68 | return { code: RCode.FAIL, msg: '用户名重复', data: '' } 69 | } 70 | user.avatar = `/avatar/avatar${Math.round(Math.random() * 19 + 1)}.png` 71 | user.role = 'user' 72 | user.userId = user.userId 73 | user.password = md5(user.password) 74 | const newUser = await this.userRepository.save(user) 75 | const payload = { userId: newUser.userId } 76 | // 默认加入群组 77 | await this.groupUserRepository.save({ 78 | userId: newUser.userId, 79 | groupId: defaultGroupId 80 | }) 81 | // 默认添加机器人为好友 82 | await this.userMapRepository.save({ 83 | userId: newUser.userId, 84 | friendId: defaultRobotId 85 | }) 86 | // 机器人欢迎语(默认留言) 87 | await this.friendMessageRepository.save({ 88 | userId: defaultRobotId, 89 | friendId: newUser.userId, 90 | content: defaultWelcomeMessage, 91 | messageType: 'text', 92 | time: new Date().valueOf() 93 | }) 94 | return { 95 | msg: '注册成功', 96 | data: { 97 | user: newUser, 98 | token: this.jwtService.sign(payload) 99 | } 100 | } 101 | } 102 | 103 | /** 104 | * 获取当前token携带信息 105 | * jwt token 106 | * @param authorization 107 | */ 108 | verifyUser(token): User { 109 | if (!token) return null 110 | const user = jwt.verify(token, jwtConstants.secret) as User 111 | return user 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /server/src/modules/auth/constants.ts: -------------------------------------------------------------------------------- 1 | export const jwtConstants = { 2 | secret: 'tyloo-chat' 3 | } 4 | -------------------------------------------------------------------------------- /server/src/modules/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy, ExtractJwt } from 'passport-jwt' 2 | import { Injectable } from '@nestjs/common' 3 | import { PassportStrategy } from '@nestjs/passport' 4 | import { jwtConstants } from './constants' 5 | import { User } from '../user/entity/user.entity' 6 | import { InjectRepository } from '@nestjs/typeorm' 7 | import { Repository } from 'typeorm' 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor( 12 | @InjectRepository(User) 13 | private readonly userRepository: Repository 14 | ) { 15 | super({ 16 | jwtFromRequest: ExtractJwt.fromHeader('token'), 17 | ignoreExpiration: false, 18 | secretOrKey: jwtConstants.secret 19 | }) 20 | } 21 | 22 | async validate(payload: User) { 23 | const user = this.userRepository.findOne({ 24 | userId: payload.userId, 25 | password: payload.password 26 | }) 27 | if (!user) { 28 | return false 29 | } 30 | return { username: payload.username, password: payload.password } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/src/modules/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { Injectable } from '@nestjs/common' 4 | import { AuthService } from './auth.service' 5 | 6 | @Injectable() 7 | export class LocalStrategy extends PassportStrategy(Strategy) { 8 | constructor(private readonly authService: AuthService) { 9 | super() 10 | } 11 | 12 | async validate(username: string, password: string): Promise { 13 | if (!username || !password) { 14 | return false 15 | } 16 | return { username, password } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/src/modules/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { GroupModule } from './../group/group.module' 2 | import { 3 | defaultGroup as defaultGroupName, 4 | defaultGroupId, 5 | defaultRobotId 6 | } from './../../common/constant/global' 7 | import { AuthModule } from './../auth/auth.module' 8 | import { Module } from '@nestjs/common' 9 | import { TypeOrmModule } from '@nestjs/typeorm' 10 | import { ChatGateway } from './chat.gateway' 11 | import { User } from '../user/entity/user.entity' 12 | import { Group, GroupMap } from '../group/entity/group.entity' 13 | import { GroupMessage } from '../group/entity/groupMessage.entity' 14 | import { UserMap } from '../friend/entity/friend.entity' 15 | import { FriendMessage } from '../friend/entity/friendMessage.entity' 16 | import { InjectRepository } from '@nestjs/typeorm' 17 | import { Repository } from 'typeorm' 18 | import { defaultRobot } from 'src/common/constant/global' 19 | import { md5 } from 'src/common/tool/utils' 20 | 21 | @Module({ 22 | imports: [ 23 | TypeOrmModule.forFeature([ 24 | User, 25 | Group, 26 | GroupMap, 27 | GroupMessage, 28 | UserMap, 29 | FriendMessage 30 | ]), 31 | AuthModule, 32 | GroupModule 33 | ], 34 | providers: [ChatGateway] 35 | }) 36 | export class ChatModule { 37 | constructor( 38 | @InjectRepository(Group) 39 | private readonly groupRepository: Repository, 40 | @InjectRepository(User) 41 | private readonly userRepository: Repository, 42 | @InjectRepository(GroupMap) 43 | private readonly groupUserRepository: Repository 44 | ) {} 45 | async onModuleInit() { 46 | // 默认新增群组 用户问题反馈群 47 | const defaultGroup = await this.groupRepository.findOne({ 48 | groupId: defaultGroupId 49 | }) 50 | if (!defaultGroup) { 51 | await this.groupRepository.save({ 52 | groupId: defaultGroupId, 53 | groupName: defaultGroupName, 54 | userId: defaultRobotId, // 群主默认为智能助手 55 | createTime: new Date().valueOf() 56 | }) 57 | console.log('create default group ' + defaultGroupName) 58 | // 机器人默认加入群组 59 | await this.groupUserRepository.save({ 60 | userId: defaultRobotId, 61 | groupId: defaultGroupId 62 | }) 63 | } 64 | 65 | // 默认新建机器人 66 | const defaultRobotArr = await this.userRepository.find({ 67 | username: defaultRobot 68 | }) 69 | if (!defaultRobotArr.length) { 70 | await this.userRepository.save({ 71 | userId: 'robot', 72 | username: defaultRobot, 73 | avatar: '/avatar/robot.png', 74 | role: 'robot', 75 | tag: '', 76 | status: 'on', 77 | createTime: new Date().valueOf(), 78 | password: md5('robot') 79 | }) 80 | console.log('create default robot ' + defaultRobot) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /server/src/modules/chat/index.d.ts: -------------------------------------------------------------------------------- 1 | // 群组 2 | interface GroupDto { 3 | groupId: string 4 | userId: string // 群主id 5 | groupName: string 6 | notice: string 7 | messages?: GroupMessageDto[] 8 | members?: FriendDto[] 9 | createTime: number 10 | } 11 | 12 | // 群消息 13 | interface GroupMessageDto { 14 | _id: number 15 | userId: string 16 | groupId: string 17 | content: string 18 | width?: number 19 | height?: number 20 | messageType: string 21 | time: number 22 | fileName?: string // 附件名称 23 | size?: number // 附件大小 24 | } 25 | 26 | // 好友 27 | interface FriendDto { 28 | userId: string 29 | username: string 30 | avatar: string 31 | role?: string 32 | tag?: string 33 | messages?: FriendMessageDto[] 34 | createTime: number 35 | online?: 1 | 0 // 是否在线 36 | isManager?: 1 | 0 // 是否为群主 37 | } 38 | 39 | // 好友消息 40 | interface FriendMessageDto { 41 | _id: number 42 | userId: string 43 | friendId: string 44 | content: string 45 | width?: number 46 | height?: number 47 | messageType: string 48 | time: number 49 | fileName?: string // 附件名称 50 | size?: number // 附件大小 51 | } 52 | 53 | // 自定义好友DTO 54 | interface UserFriendMap { 55 | userId: string 56 | friendId: string 57 | friendUserName: string 58 | } 59 | 60 | // 邀请好友入群DTO 61 | interface FriendsIntoGroup { 62 | friendIds: string[] // 被邀请人 63 | groupId: string // 群组ID 64 | userId: string // 邀请人 65 | } 66 | -------------------------------------------------------------------------------- /server/src/modules/friend/entity/friend.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm' 2 | 3 | @Entity() 4 | export class UserMap { 5 | @PrimaryGeneratedColumn() 6 | _id: number 7 | 8 | @Column() 9 | friendId: string 10 | 11 | @Column() 12 | userId: string 13 | } 14 | -------------------------------------------------------------------------------- /server/src/modules/friend/entity/friendMessage.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm' 2 | 3 | @Entity() 4 | export class FriendMessage { 5 | @PrimaryGeneratedColumn() 6 | _id: number 7 | 8 | @Column() 9 | userId: string 10 | 11 | @Column() 12 | friendId: string 13 | 14 | @Column() 15 | content: string 16 | 17 | @Column() 18 | messageType: string 19 | 20 | @Column('double') 21 | time: number 22 | } 23 | -------------------------------------------------------------------------------- /server/src/modules/friend/friend.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query, UseGuards } from '@nestjs/common' 2 | import { FriendService } from './friend.service' 3 | import { AuthGuard } from '@nestjs/passport' 4 | 5 | @Controller('friend') 6 | @UseGuards(AuthGuard('jwt')) 7 | export class FriendController { 8 | constructor(private readonly friendService: FriendService) {} 9 | 10 | @Get() 11 | getFriends(@Query('userId') userId: string) { 12 | return this.friendService.getFriends(userId) 13 | } 14 | 15 | @Get('/friendMessages') 16 | getFriendMessage(@Query() query: any) { 17 | return this.friendService.getFriendMessages( 18 | query.userId, 19 | query.friendId, 20 | query.current, 21 | query.pageSize 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/modules/friend/friend.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { FriendController } from './friend.controller' 3 | import { FriendService } from './friend.service' 4 | import { TypeOrmModule } from '@nestjs/typeorm' 5 | import { UserMap } from './entity/friend.entity' 6 | import { FriendMessage } from './entity/friendMessage.entity' 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([UserMap, FriendMessage])], 10 | controllers: [FriendController], 11 | providers: [FriendService] 12 | }) 13 | export class FriendModule {} 14 | -------------------------------------------------------------------------------- /server/src/modules/friend/friend.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { Repository, getRepository } from 'typeorm' 3 | import { InjectRepository } from '@nestjs/typeorm' 4 | import { UserMap } from './entity/friend.entity' 5 | import { FriendMessage } from './entity/friendMessage.entity' 6 | import { RCode } from 'src/common/constant/rcode' 7 | 8 | @Injectable() 9 | export class FriendService { 10 | constructor( 11 | @InjectRepository(UserMap) 12 | private readonly friendRepository: Repository, 13 | @InjectRepository(FriendMessage) 14 | private readonly friendMessageRepository: Repository 15 | ) {} 16 | 17 | async getFriends(userId: string) { 18 | try { 19 | if (userId) { 20 | return { 21 | msg: '获取用户好友成功', 22 | data: await this.friendRepository.find({ userId: userId }) 23 | } 24 | } else { 25 | return { 26 | msg: '获取用户好友失败', 27 | data: await this.friendRepository.find() 28 | } 29 | } 30 | } catch (e) { 31 | return { code: RCode.ERROR, msg: '获取用户好友失败', data: e } 32 | } 33 | } 34 | 35 | async getFriendMessages( 36 | userId: string, 37 | friendId: string, 38 | current: number, 39 | pageSize: number 40 | ) { 41 | const messages = await getRepository(FriendMessage) 42 | .createQueryBuilder('friendMessage') 43 | .orderBy('friendMessage.time', 'DESC') 44 | .where( 45 | 'friendMessage.userId = :userId AND friendMessage.friendId = :friendId', 46 | { userId: userId, friendId: friendId } 47 | ) 48 | .orWhere( 49 | 'friendMessage.userId = :friendId AND friendMessage.friendId = :userId', 50 | { userId: userId, friendId: friendId } 51 | ) 52 | .skip(current) 53 | .take(pageSize) 54 | .getMany() 55 | return { msg: '', data: { messageArr: messages.reverse() } } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server/src/modules/group/entity/group.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm' 2 | 3 | @Entity() 4 | export class Group { 5 | @PrimaryGeneratedColumn('uuid') 6 | groupId: string 7 | 8 | @Column() 9 | userId: string 10 | 11 | @Column() 12 | groupName: string 13 | 14 | @Column({ default: '群主很懒,没写公告' }) 15 | notice: string 16 | 17 | @Column({ type: 'double', default: new Date().valueOf() }) 18 | createTime: number 19 | } 20 | 21 | @Entity() 22 | export class GroupMap { 23 | @PrimaryGeneratedColumn() 24 | _id: number 25 | 26 | @Column() 27 | groupId: string 28 | 29 | @Column() 30 | userId: string 31 | 32 | @Column({ 33 | type: 'double', 34 | default: new Date().valueOf(), 35 | comment: '进群时间' 36 | }) 37 | createTime: number 38 | } 39 | -------------------------------------------------------------------------------- /server/src/modules/group/entity/groupMessage.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm' 2 | 3 | @Entity() 4 | export class GroupMessage { 5 | @PrimaryGeneratedColumn() 6 | _id: number 7 | 8 | @Column() 9 | userId: string 10 | 11 | @Column() 12 | groupId: string 13 | 14 | @Column() 15 | content: string 16 | 17 | @Column() 18 | messageType: string 19 | 20 | @Column('double') 21 | time: number 22 | } 23 | -------------------------------------------------------------------------------- /server/src/modules/group/group.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Get, Body, Query, UseGuards } from '@nestjs/common' 2 | import { GroupService } from './group.service' 3 | import { AuthGuard } from '@nestjs/passport' 4 | 5 | @Controller('group') 6 | @UseGuards(AuthGuard('jwt')) 7 | export class GroupController { 8 | constructor(private readonly groupService: GroupService) {} 9 | 10 | @Post() 11 | postGroups(@Body('groupIds') groupIds: string) { 12 | return this.groupService.postGroups(groupIds) 13 | } 14 | 15 | @Get('/userGroup') 16 | getUserGroups(@Query('userId') userId: string) { 17 | return this.groupService.getUserGroups(userId) 18 | } 19 | 20 | @Get('/findByName') 21 | getGroupsByName(@Query('groupName') groupName: string) { 22 | return this.groupService.getGroupsByName(groupName) 23 | } 24 | 25 | @Get('/groupMessages') 26 | getGroupMessages( 27 | @Query('userId') userId: string, 28 | @Query('groupId') groupId: string, 29 | @Query('current') current: number, 30 | @Query('pageSize') pageSize: number 31 | ) { 32 | return this.groupService.getGroupMessages( 33 | userId, 34 | groupId, 35 | current, 36 | pageSize 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/src/modules/group/group.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | import { GroupService } from './group.service' 4 | import { GroupController } from './group.controller' 5 | import { Group, GroupMap } from './entity/group.entity' 6 | import { GroupMessage } from './entity/groupMessage.entity' 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Group, GroupMap, GroupMessage])], 10 | providers: [GroupService], 11 | controllers: [GroupController], 12 | exports: [GroupService] 13 | }) 14 | export class GroupModule {} 15 | -------------------------------------------------------------------------------- /server/src/modules/group/group.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { Repository, Like, getRepository } from 'typeorm' 3 | import { InjectRepository } from '@nestjs/typeorm' 4 | import { Group, GroupMap } from './entity/group.entity' 5 | import { GroupMessage } from './entity/groupMessage.entity' 6 | import { RCode } from 'src/common/constant/rcode' 7 | import { User } from '../user/entity/user.entity' 8 | import { defaultGroupMessageTime } from 'src/common/constant/global' 9 | 10 | @Injectable() 11 | export class GroupService { 12 | constructor( 13 | @InjectRepository(Group) 14 | private readonly groupRepository: Repository, 15 | @InjectRepository(GroupMap) 16 | private readonly groupUserRepository: Repository 17 | ) {} 18 | 19 | async postGroups(groupIds: string) { 20 | try { 21 | if (groupIds) { 22 | const groupIdArr = groupIds.split(',') 23 | const groupArr = [] 24 | for (const groupId of groupIdArr) { 25 | const data = await this.groupRepository.findOne({ groupId: groupId }) 26 | groupArr.push(data) 27 | } 28 | return { msg: '获取群信息成功', data: groupArr } 29 | } 30 | return { code: RCode.FAIL, msg: '获取群信息失败', data: null } 31 | } catch (e) { 32 | return { code: RCode.ERROR, msg: '获取群失败', data: e } 33 | } 34 | } 35 | 36 | async getUserGroups(userId: string) { 37 | try { 38 | let data 39 | if (userId) { 40 | data = await this.groupUserRepository.find({ userId: userId }) 41 | return { msg: '获取用户所有群成功', data } 42 | } 43 | data = await this.groupUserRepository.find() 44 | return { msg: '获取系统所有群成功', data } 45 | } catch (e) { 46 | return { code: RCode.ERROR, msg: '获取用户的群失败', data: e } 47 | } 48 | } 49 | 50 | async getGroupMessages( 51 | userId: string, 52 | groupId: string, 53 | current: number, 54 | pageSize: number 55 | ) { 56 | const groupUser = await this.groupUserRepository.findOne({ 57 | userId, 58 | groupId 59 | }) 60 | const { createTime } = groupUser 61 | let groupMessage = await getRepository(GroupMessage) 62 | .createQueryBuilder('groupMessage') 63 | .orderBy('groupMessage.time', 'DESC') 64 | .where('groupMessage.groupId = :id', { id: groupId }) 65 | .andWhere('groupMessage.time >= :createTime', { 66 | createTime: createTime - defaultGroupMessageTime // 新用户进群默认可以看群近24小时消息 67 | }) 68 | .skip(current) 69 | .take(pageSize) 70 | .getMany() 71 | groupMessage = groupMessage.reverse() 72 | 73 | const userGather: { [key: string]: User } = {} 74 | let userArr: FriendDto[] = [] 75 | for (const message of groupMessage) { 76 | if (!userGather[message.userId]) { 77 | userGather[message.userId] = await getRepository(User) 78 | .createQueryBuilder('user') 79 | .where('user.userId = :id', { id: message.userId }) 80 | .getOne() 81 | } 82 | } 83 | userArr = Object.values(userGather) 84 | return { msg: '', data: { messageArr: groupMessage, userArr: userArr } } 85 | } 86 | 87 | async getGroupsByName(groupName: string) { 88 | try { 89 | if (groupName) { 90 | const groups = await this.groupRepository.find({ 91 | groupName: Like(`%${groupName}%`) 92 | }) 93 | return { data: groups } 94 | } 95 | return { code: RCode.FAIL, msg: '请输入群昵称', data: null } 96 | } catch (e) { 97 | return { code: RCode.ERROR, msg: '查找群错误', data: null } 98 | } 99 | } 100 | 101 | async update(group: GroupDto) { 102 | try { 103 | await this.groupRepository.update(group.groupId, group) 104 | return { 105 | code: RCode.OK, 106 | msg: '修改成功', 107 | data: group 108 | } 109 | } catch (e) { 110 | return { code: RCode.ERROR, msg: '更新失败', data: null } 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /server/src/modules/user/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm' 2 | 3 | @Entity() 4 | export class User { 5 | @PrimaryGeneratedColumn('uuid') 6 | userId: string 7 | 8 | @Column({ default: 'test' }) 9 | username: string 10 | 11 | @Column({ default: '123456', select: false }) 12 | password: string 13 | 14 | @Column({ default: 'avatar1.png' }) 15 | avatar: string 16 | 17 | @Column({ default: 'user' }) 18 | role: string 19 | 20 | @Column({ default: 'on' }) 21 | status: string 22 | 23 | @Column({ default: '' }) 24 | tag: string 25 | 26 | @Column({ type: 'double', default: new Date().valueOf() }) 27 | createTime: number 28 | } 29 | -------------------------------------------------------------------------------- /server/src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Get, 5 | Body, 6 | Query, 7 | Patch, 8 | Delete, 9 | UseInterceptors, 10 | UploadedFile, 11 | UseGuards, 12 | Req 13 | } from '@nestjs/common' 14 | import { UserService } from './user.service' 15 | import { AuthService } from './../auth/auth.service' 16 | import { FileInterceptor } from '@nestjs/platform-express' 17 | import { AuthGuard } from '@nestjs/passport' 18 | 19 | @Controller('user') 20 | @UseGuards(AuthGuard('jwt')) 21 | export class UserController { 22 | constructor( 23 | private readonly userService: UserService, 24 | private readonly authService: AuthService 25 | ) {} 26 | 27 | @Get() 28 | getUsers(@Query('userId') userId: string) { 29 | return this.userService.getUser(userId) 30 | } 31 | 32 | @Post() 33 | postUsers(@Body('userIds') userIds: string) { 34 | return this.userService.postUsers(userIds) 35 | } 36 | 37 | @Patch('username') 38 | updateUserName(@Req() req, @Query('username') username) { 39 | const oldUser = this.authService.verifyUser(req.headers.token) 40 | return this.userService.updateUserName(oldUser, username) 41 | } 42 | 43 | @Patch('password') 44 | updatePassword(@Req() req, @Query('password') password) { 45 | const user = this.authService.verifyUser(req.headers.token) 46 | return this.userService.updatePassword(user, password) 47 | } 48 | 49 | @Delete() 50 | delUser(@Req() req, @Query() { did }) { 51 | const user = this.authService.verifyUser(req.headers.token) 52 | return this.userService.delUser(user, did) 53 | } 54 | 55 | @Get('/findByName') 56 | getUsersByName(@Query('username') username: string) { 57 | return this.userService.getUsersByName(username) 58 | } 59 | 60 | @Post('/avatar') 61 | @UseInterceptors(FileInterceptor('avatar')) 62 | setUserAvatar(@Req() req, @UploadedFile() file) { 63 | const user = this.authService.verifyUser(req.headers.token) 64 | return this.userService.setUserAvatar(user, file) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /server/src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { AuthModule } from './../auth/auth.module' 2 | import { Module } from '@nestjs/common' 3 | import { TypeOrmModule } from '@nestjs/typeorm' 4 | import { User } from '../user/entity/user.entity' 5 | import { UserController } from './user.controller' 6 | import { UserService } from './user.service' 7 | import { Group, GroupMap } from '../group/entity/group.entity' 8 | import { GroupMessage } from '../group/entity/groupMessage.entity' 9 | import { UserMap } from '../friend/entity/friend.entity' 10 | import { FriendMessage } from '../friend/entity/friendMessage.entity' 11 | 12 | @Module({ 13 | imports: [ 14 | TypeOrmModule.forFeature([ 15 | User, 16 | Group, 17 | GroupMap, 18 | GroupMessage, 19 | UserMap, 20 | FriendMessage 21 | ]), 22 | AuthModule 23 | ], 24 | providers: [UserService], 25 | controllers: [UserController] 26 | }) 27 | export class UserModule {} 28 | -------------------------------------------------------------------------------- /server/src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { Repository, Like } from 'typeorm' 3 | import { InjectRepository } from '@nestjs/typeorm' 4 | import { User } from './entity/user.entity' 5 | import { Group, GroupMap } from '../group/entity/group.entity' 6 | import { createWriteStream } from 'fs' 7 | import { join } from 'path' 8 | import { RCode } from 'src/common/constant/rcode' 9 | import { GroupMessage } from '../group/entity/groupMessage.entity' 10 | import { UserMap } from '../friend/entity/friend.entity' 11 | import { FriendMessage } from '../friend/entity/friendMessage.entity' 12 | import { md5 } from 'src/common/tool/utils' 13 | import { AuthService } from './../auth/auth.service' 14 | 15 | @Injectable() 16 | export class UserService { 17 | constructor( 18 | @InjectRepository(User) 19 | private readonly userRepository: Repository, 20 | @InjectRepository(Group) 21 | private readonly groupRepository: Repository, 22 | @InjectRepository(GroupMap) 23 | private readonly groupUserRepository: Repository, 24 | @InjectRepository(GroupMessage) 25 | private readonly groupMessageRepository: Repository, 26 | @InjectRepository(UserMap) 27 | private readonly friendRepository: Repository, 28 | @InjectRepository(FriendMessage) 29 | private readonly friendMessageRepository: Repository, 30 | private readonly authService: AuthService 31 | ) {} 32 | 33 | async getUser(userId: string) { 34 | try { 35 | let data 36 | if (userId) { 37 | data = await this.userRepository.findOne({ 38 | where: { userId: userId } 39 | }) 40 | return { msg: '获取用户成功', data } 41 | } 42 | } catch (e) { 43 | return { code: RCode.ERROR, msg: '获取用户失败', data: e } 44 | } 45 | } 46 | 47 | async postUsers(userIds: string) { 48 | try { 49 | if (userIds) { 50 | const userIdArr = userIds.split(',') 51 | const userArr = [] 52 | for (const userId of userIdArr) { 53 | if (userId) { 54 | const data = await this.userRepository.findOne({ 55 | where: { userId: userId } 56 | }) 57 | userArr.push(data) 58 | } 59 | } 60 | return { msg: '获取用户信息成功', data: userArr } 61 | } 62 | return { code: RCode.FAIL, msg: '获取用户信息失败', data: null } 63 | } catch (e) { 64 | return { code: RCode.ERROR, msg: '获取用户信息失败', data: e } 65 | } 66 | } 67 | 68 | async updateUserName(oldUser: User, username: string) { 69 | try { 70 | if (oldUser) { 71 | const isHaveName = await this.userRepository.findOne({ 72 | username 73 | }) 74 | if (isHaveName) { 75 | return { code: 1, msg: '用户名重复', data: '' } 76 | } 77 | const newUser = await this.userRepository.findOne({ 78 | userId: oldUser.userId 79 | }) 80 | newUser.username = username 81 | await this.userRepository.update(oldUser.userId, newUser) 82 | return { msg: '更新用户名成功', data: newUser } 83 | } 84 | return { code: RCode.FAIL, msg: '更新失败', data: '' } 85 | } catch (e) { 86 | return { code: RCode.ERROR, msg: '更新用户名失败', data: e } 87 | } 88 | } 89 | 90 | async updatePassword(user: User, password: string) { 91 | try { 92 | if (user) { 93 | const newUser = await this.userRepository.findOne({ 94 | userId: user.userId 95 | }) 96 | const backUser = JSON.parse(JSON.stringify(newUser)) 97 | newUser.password = md5(password) 98 | await this.userRepository.update(user.userId, newUser) 99 | return { msg: '更新用户密码成功', data: backUser } 100 | } 101 | return { code: RCode.FAIL, msg: '更新失败', data: '' } 102 | } catch (e) { 103 | return { code: RCode.ERROR, msg: '更新用户密码失败', data: e } 104 | } 105 | } 106 | 107 | async delUser(user: User, did: string) { 108 | try { 109 | if (user.role === 'admin') { 110 | // 被删用户自己创建的群 111 | const groups = await this.groupRepository.find({ userId: did }) 112 | for (const group of groups) { 113 | await this.groupRepository.delete({ groupId: group.groupId }) 114 | await this.groupUserRepository.delete({ groupId: group.groupId }) 115 | await this.groupMessageRepository.delete({ groupId: group.groupId }) 116 | } 117 | // 被删用户加入的群 118 | await this.groupUserRepository.delete({ userId: did }) 119 | await this.groupMessageRepository.delete({ userId: did }) 120 | // 被删用户好友 121 | await this.friendRepository.delete({ userId: did }) 122 | await this.friendRepository.delete({ friendId: did }) 123 | await this.friendMessageRepository.delete({ userId: did }) 124 | await this.friendMessageRepository.delete({ friendId: did }) 125 | await this.userRepository.delete({ userId: did }) 126 | return { msg: '用户删除成功' } 127 | } 128 | return { code: RCode.FAIL, msg: '用户删除失败' } 129 | } catch (e) { 130 | return { code: RCode.ERROR, msg: '用户删除失败', data: e } 131 | } 132 | } 133 | 134 | async getUsersByName(username: string) { 135 | try { 136 | if (username) { 137 | const users = await this.userRepository.find({ 138 | where: { username: Like(`%${username}%`) } 139 | }) 140 | return { data: users } 141 | } 142 | return { code: RCode.FAIL, msg: '请输入用户名', data: null } 143 | } catch (e) { 144 | return { code: RCode.ERROR, msg: '查找用户错误', data: null } 145 | } 146 | } 147 | 148 | async setUserAvatar(user: User, file) { 149 | const newUser = await this.userRepository.findOne({ 150 | userId: user.userId 151 | }) 152 | if (newUser) { 153 | const random = Date.now() + '&' 154 | const stream = createWriteStream( 155 | join('public/avatar', random + file.originalname) 156 | ) 157 | stream.write(file.buffer) 158 | newUser.avatar = `/avatar/${random}${file.originalname}` 159 | await this.userRepository.save(newUser) 160 | return { msg: '修改头像成功', data: newUser } 161 | } else { 162 | return { code: RCode.FAIL, msg: '修改头像失败' } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { INestApplication } from '@nestjs/common' 3 | import * as request from 'supertest' 4 | import { AppModule } from './../src/app.module' 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule] 12 | }).compile() 13 | 14 | app = moduleFixture.createNestApplication() 15 | await app.init() 16 | }) 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /webSocket建立流程.md: -------------------------------------------------------------------------------- 1 | 7 | - websocket流程 8 | - 建立连接 9 | - 用户默认加入userId房间 10 | - 默认加入defaultGroup房间 11 | - 广播给所有其他在线用户,我上线了 12 | - 初始化聊天数据 13 | - socket.emit('chatData', user); 14 | - 获取当前账号所有好友 (friendGather) 15 | - 获取当前账号已加入的群 (groupGather) 16 | - 获取群聊记录 (group.messages) 17 | - 获取私聊记录 (friend.messages) 18 | - 获取群成员信息 (group.members) 19 | - 获取跟当前账号有关的所有用户信息(userGather) 20 | - 包括friendGataher 21 | - 群聊记录中非好友的用户信息 22 | - this.server.to(user.userId).emit('chatData',xxx) 23 | - vuex初始化数据 (handleChatData) 24 | - 建立私聊房间socket连接 (joinFriendSocket) 25 | - 建立群聊房间socket连接 (joinGroupSocket) 26 | - 保存数据 27 | - commit(SET_GROUP_GATHER, group); 28 | - commit(SET_FRIEND_GATHER, friend); 29 | - commit(SET_USER_GATHER, user_); 30 | - 设置/刷新activeRoom 31 | - 默认为DEFAULT_GROUP 32 | - 已有activeRoom则重新赋值activeRoom已便Watch触发监听 33 | - 断开连接 34 | - 广播给所有其他在线用户,我下线了 35 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | --------------------------------------------------------------------------------