├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc.js ├── .sentryclirc ├── .stylelintignore ├── .stylelintrc.js ├── LICENSE ├── README.md ├── assets ├── export02.png ├── export03.png ├── export04.png ├── export05.png ├── export06.png ├── export07.png ├── mindmap-datastream.png └── mindmap.drawio ├── auto-imports.d.ts ├── babel.config.js ├── commitlint.config.js ├── components.d.ts ├── jsconfig.json ├── note.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.vue ├── assets │ ├── css │ │ ├── handler.scss │ │ ├── mixin.scss │ │ ├── reset.css │ │ └── themes.scss │ ├── logo.svg │ ├── map │ │ ├── add.svg │ │ ├── arrow-left.svg │ │ └── loading.svg │ └── pic │ │ ├── 404.svg │ │ ├── add-file.svg │ │ ├── add-quick.svg │ │ ├── add.svg │ │ ├── arrow-left.svg │ │ ├── arrow-right.svg │ │ ├── delete.svg │ │ ├── download.svg │ │ ├── empty.png │ │ ├── file-large.svg │ │ ├── file-small.svg │ │ ├── fit-view.svg │ │ ├── folder-large.svg │ │ ├── folder.svg │ │ ├── grid.svg │ │ ├── hamberger.svg │ │ ├── home.svg │ │ ├── latest.svg │ │ ├── loading.svg │ │ ├── logout.svg │ │ ├── marker.svg │ │ ├── more.svg │ │ ├── note.svg │ │ ├── pc.png │ │ ├── qrcode.png │ │ ├── quick.svg │ │ ├── refresh.png │ │ ├── remove.svg │ │ ├── rename.svg │ │ ├── search.svg │ │ ├── settings.svg │ │ ├── skin.svg │ │ ├── success.png │ │ ├── table.svg │ │ ├── theme.svg │ │ ├── trash.svg │ │ ├── tree.svg │ │ ├── triangle.svg │ │ ├── upload.svg │ │ └── workwith.svg ├── components │ ├── DocPopover.vue │ ├── SvgIcon.vue │ ├── map │ │ ├── Map-beta.vue │ │ ├── Map.vue │ │ ├── MapBar.vue │ │ ├── MapOpPopover.vue │ │ └── MapRender.vue │ └── note │ │ ├── Note.vue │ │ └── NotePopover.vue ├── hooks │ ├── http │ │ ├── apis.js │ │ ├── axios.js │ │ └── index.js │ ├── map.worker.js │ ├── map │ │ ├── LogicTree-beta.js │ │ ├── LogicTree.js │ │ ├── LogicTree1.js │ │ ├── Tree.js │ │ ├── TreeTable.js │ │ ├── useAutoZoom.js │ │ ├── useMap-beta.js │ │ ├── useMap.js │ │ ├── useMap1.js │ │ └── zoomMap.js │ ├── note │ │ └── useNote.js │ ├── svg2Png.js │ ├── useContent.js │ ├── useIntroStyle.js │ ├── useLogin.js │ ├── useMapStyle.js │ ├── useSnapshot.js │ ├── useWorker.js │ └── utils.js ├── main.js ├── router │ └── index.js ├── store │ ├── doc.js │ ├── index.js │ ├── map.js │ ├── user.js │ └── website.js └── views │ ├── Edit.vue │ ├── Folder │ ├── components │ │ └── BreadCrumb.vue │ └── index.vue │ ├── Home │ ├── components │ │ ├── ProfilePopover.vue │ │ └── Sider.vue │ └── index.vue │ ├── Intro.vue │ ├── Login │ ├── components │ │ └── Qrcode.vue │ └── index.vue │ └── NotFound.vue └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | [*] 7 | # lf, cr, or crlf 8 | end_of_line = lf 9 | # ensure file ends with a newline when saving 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | vue.config.js 2 | src/views/A.js 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'airbnb-base', 8 | 'plugin:vue/vue3-essential', 9 | '@vue/standard', 10 | 'prettier', 11 | 'plugin:prettier/recommended' 12 | ], 13 | parserOptions: { 14 | parser: 'babel-eslint' 15 | }, 16 | rules: { 17 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 18 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 19 | 'vue/multi-word-component-names': 0, 20 | 'import/extensions': 'off', 21 | 'arrow-parens': 'off', 22 | 'import/no-cycle': 'warn', 23 | 'no-underscore-dangle': 'off', 24 | 'class-methods-use-this': 'off', 25 | 'prefer-destructuring': 'off' 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | # push: 9 | # branches: [ main ] 10 | # pull_request: 11 | # branches: [ main ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-node@v2 #nodejs 环境 28 | with: 29 | node-version: '14' #nodejs 版本 30 | - run: npm install #安装依赖 31 | - run: npm run build --if-present #打包 32 | 33 | - name: Tar dist file 34 | run: | 35 | tar -zcvf mindmap.tar.gz dist # 打包dist文件夹 36 | - name: Scp_upload_tar_file # 上传至服务器 37 | uses: garygrossgarten/github-action-scp@release 38 | with: 39 | local: 'mindmap.tar.gz' 40 | remote: '/www/wwwroot/mindmap.tar.gz' 41 | host: ${{ secrets.HOST }} 42 | username: ${{ secrets.USERNAME }} 43 | password: ${{ secrets.PASSWORD }} 44 | port: ${{ secrets.PORT }} 45 | - name: Deploy_app_tar #解压压缩包 46 | uses: appleboy/ssh-action@master 47 | with: 48 | host: ${{ secrets.HOST }} 49 | username: ${{ secrets.USERNAME }} 50 | password: ${{ secrets.PASSWORD }} 51 | port: ${{ secrets.PORT }} 52 | script: | 53 | cd /www/wwwroot/ 54 | rm -rf mindmap 55 | tar -zxvf mindmap.tar.gz 56 | mv dist mindmap 57 | rm -rf mindmap.tar.gz 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | output_prod.js 6 | output_dev.js 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | .sentryclirc 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | pnpm-debug.log* 17 | 18 | # Editor directories and files 19 | .idea 20 | .vscode 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | components.d.ts 27 | auto-imports.d.ts 28 | /loaders 29 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # npx --no-install pretty-quick --staged 5 | npx --no-install lint-staged 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // 代码结尾是否加分号 3 | semi: false, 4 | // 是否使用单引号 5 | singleQuote: true, 6 | // 对象大括号内两边是否加空格 { a:0 } 7 | bracketSpacing: true, 8 | // 单个参数的箭头函数不加括号 x => x 9 | arrowParens: 'avoid', 10 | // 超过多少字符强制换行 11 | printWidth: 80, 12 | // 文件换行格式 LF/CRLF 13 | endOfLine: 'lf', 14 | // 使用 2 个空格缩进 15 | tabWidth: 2, 16 | // 不使用缩进符,而使用空格 17 | useTabs: false, 18 | // 对象的 key 仅在必要时用引号 19 | quoteProps: 'as-needed', 20 | // jsx 不使用单引号,而使用双引号 21 | jsxSingleQuote: false, 22 | // 末尾不需要逗号 23 | trailingComma: 'none', 24 | // jsx 标签的反尖括号需要换行 25 | jsxBracketSameLine: false, 26 | // 每个文件格式化的范围是文件的全部内容 27 | rangeStart: 0, 28 | rangeEnd: Infinity, 29 | // 不需要写文件开头的 @prettier 30 | requirePragma: false, 31 | // 不需要自动在文件开头插入 @prettier 32 | insertPragma: false, 33 | // 使用默认的折行标准 34 | proseWrap: 'preserve', 35 | // 根据显示样式决定 html 要不要折行 36 | htmlWhitespaceSensitivity: 'css' 37 | } 38 | -------------------------------------------------------------------------------- /.sentryclirc: -------------------------------------------------------------------------------- 1 | [auth] 2 | token = 0a50065d170e45a6b066c1b53800496ea72ef418688f4fa68c31b2c88fc646ab 3 | 4 | [defaults] 5 | url = https://sentry.io/ 6 | org = zy1996 7 | project = zmindmap 8 | 9 | [attention] 10 | tip = please use your own configs!!! 11 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | # .stylelintignore 2 | # 旧的不需打包的样式库 3 | *.min.css 4 | 5 | # 其他类型文件 6 | *.js 7 | *.jpg 8 | *.woff 9 | 10 | # 测试和打包目录 11 | /test/ 12 | /dist/ 13 | 14 | # 通过反取忽略目录 15 | # /src/component/* 16 | # !/src/component/CompA 17 | # !/src/component/CompB 18 | # 这样的效果是除 CompA 和 CompB 外其他目录都会被忽略 19 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'stylelint-config-standard', 4 | 'stylelint-config-recess-order', 5 | 'stylelint-config-prettier' 6 | ], 7 | rules: { 8 | 'at-rule-no-unknown': [ 9 | true, 10 | { 11 | ignoreAtRules: [ 12 | 'mixin', 13 | 'include', 14 | 'extend', 15 | 'each', 16 | 'function', 17 | 'return' 18 | ] 19 | } 20 | ], 21 | // "order/properties-order": null, 22 | 'declaration-empty-line-before': null, 23 | 'no-descending-specificity': null, 24 | 'selector-pseudo-class-no-unknown': null 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 zyascend 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 |
2 | 3 |
4 |

5 |

6 |

7 | 8 | MIT License 9 | 10 | 11 | Vue3.2 12 | 13 | 14 | Live Demo 15 | 16 |

17 | 18 | ## 简介 19 | **仿[幕布](https://mubu.com)思维导图网站。支持导图编辑、大纲编辑、图片导出、扫码登录。** 20 | 21 | ## 项目地址: 22 | 项目总结:[ZMindMap-Wiki](https://github.com/zyascend/ZMindMap/wiki) 23 | 24 | 预览地址:[ZMind思维导图](https://map.kimjisoo.cn) 25 | 26 | 移动端地址: [ZMindMap-Mobile](https://github.com/zyascend/ZMindMap-Mobile) 27 | 28 | Node端地址:[mind-map-node](https://github.com/zyascend/mind-map-node) 29 | 30 | ## 下载&安装 31 | 32 | - 下载 33 | 34 | ```bash 35 | git clone --depth=1 https://github.com/zyascend/ZMindMap.git 36 | ``` 37 | 38 | - 进入项目目录 39 | ```bash 40 | cd ZmindMap 41 | ``` 42 | - 安装依赖 43 | 44 | ```bash 45 | npm install 46 | ``` 47 | 48 | - 运行 49 | ```bash 50 | npm run serve 51 | ``` 52 | ## 效果图 53 | 54 | | | | 55 | | :------------------------------------------------------------------------------: | -------------------------------------------------------------------------------- | 56 | | | | 57 | | | | 58 | 59 | 视频版:[点击播放](https://cdn.kimjisoo.cn/videos%2Fpresentation_v1.0.mp4) 60 | 61 | ## Features 62 | - Vue3 CompositionApi 63 | - Pinia状态管理 64 | - VueRouter路由控制 65 | - SVG画图 66 | - 类幕布思维导图的文档构建方式实现 67 | - 数据驱动UI的思路 68 | - svg导出为png图片 69 | - Element-plus 70 | - splitChunks单独打包 71 | - 基于七牛云的CDN加速 72 | - JWT & 二维码扫码登录 73 | - 夜间模式 74 | - 前端监控 75 | - 使用Sentry收集错误信息 76 | - 百度统计 77 | 78 | ## TODOs 79 | - [x] 基于vue响应式,通过数据驱动svg子元素更新 80 | - [x] 对于大纲编辑,如何不通过递归查找的方式在源数据中定位到待更新的节点 81 | - [x] key-value形式构建map 82 | - [x] Vuex切换为pinia 83 | - [x] store分模块维护 84 | - [x] 支持撤回操作 85 | - [x] bug fix 86 | - [x] 导图风格切换 87 | - [x] 支持导出 88 | - [x] 导出为图片 89 | - [x] 图片不显示 bug fix 90 | - [ ] 导出为其他格式 91 | - [x] 二维码扫码登录 92 | - [x] 轮询接口查状态 => websocket 93 | - [x] 大纲编辑页相关优化 94 | - [x] 防止XSS攻击 95 | - [x] 支持添加图片 96 | - [x] 重写节点宽高计算逻辑 97 | - [x] 全面重写MindMap组件 98 | - [x] 代码臃肿:分离UI渲染部分和数据部分 99 | - [x] 可拓展性:提取各种样式导图的公共dom结构 方便切换导图风格 100 | - [x] 公共逻辑抽取 方便随时切换颜色样式 101 | - [x] 导图计算:抽取公共逻辑 + 继承封装 102 | - [x] map store 逻辑优化 103 | - [ ] 页面的loading 和 错误处理 104 | - [ ] 监听全局异常 error boundary 105 | - [ ] 使用自定义的loader处理svg图标 106 | - [ ] 将svg icon处理为SFC 107 | - [ ] 封装SFC为Icon组件:绑定属性 灵活使用 108 | - [x] 所有配置项由`window.CFG`注入 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /assets/export02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/assets/export02.png -------------------------------------------------------------------------------- /assets/export03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/assets/export03.png -------------------------------------------------------------------------------- /assets/export04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/assets/export04.png -------------------------------------------------------------------------------- /assets/export05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/assets/export05.png -------------------------------------------------------------------------------- /assets/export06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/assets/export06.png -------------------------------------------------------------------------------- /assets/export07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/assets/export07.png -------------------------------------------------------------------------------- /assets/mindmap-datastream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/assets/mindmap-datastream.png -------------------------------------------------------------------------------- /assets/mindmap.drawio: -------------------------------------------------------------------------------- 1 | 5Ztbc6M2FIB/jR7jAUncHsHG2850O51mOu0+EiPbdLHlAk7s/fWVQMJc5BhscJzZ7MxGHIEQ5/Lp6BKAppvDlyTYrb/SkMQAauEBoBmA0LF19j8XHAuBgWAhWCVRWIj0k+A5+kGEUBPSfRSStHZjRmmcRbu6cEG3W7LIarIgSehb/bYljetv3QUr0hI8L4K4Lf07CrN1IbWhdZL/QqLVWr5ZN52iZhPIm8WXpOsgpG8VEfIBmiaUZkVpc5iSmOtO6qV4bn6mtuxYQrZZlwewWTzxGsR78XGiY9lRfm1C99uQ8Ac0gLy3dZSR512w4LVvzLxMts42MbvSWbHdAdGnV5Jk5FARiQ59IXRDsuTIbhG1liOUI7xDl8p6O+kaSVdYV/QMLSEMhH1XZdsnFbCC0IJaI/DxFKKjywrBaCyFYNxSAAlZOIjLLd2S+hezD02O/wjt5Bff+MXEkJezQ7VydhRXZzWV0n2yEK9GItyDZEXkV9qFjPfqXXUmJA6y6LUexCrV5I+6SRIcKzfsaLTN0krLf3DByUoQGzUrYaw11Fy0eFJ62bVudjDanumbwPGAh/OCCTwN+AZw58Cb8gKv8nnB9oEzz6ssYHv5zUZ+swnsGXB9pYF/C14YtWuWDeJotWXlBbMQSZiA+3DEuOiKik0UhrwNLyFp9CN4ydvjthXKY40bHjBmSmtLR2sFRgly0WCNlaqAedImlmHaNXM8iab6OUHLynqtUVR/nC6XKfPKZnD1szNSAMiMmY68Jc17sqAxTfIa8789HyiYLtFyidhPVWSu+O+vwe5PwmiVTF5Zc6Khl0RWcxfwbOAVrjQHDgI+5hLmMr4DHMy9w7e4Kzl2XuXmHsQKzLnM3KcwsHF+jwZsU76CfWXRXfGeD2eoWWcodNoM1S0FQ80hxhTUE6GdUIhHwV7L47FR15zkWtlEwWLx1E2uDxXZSCfEmcDVgGdxCZPb80dFXBncNyNOm7AcCI5POGMEwuG2lW8jnBe8g7emZ7DhkOHNkp4BH5VYyOpALHMkYiGjJ7FOSd8pz/tWS/N6Jn21DM+6D+pgY5DQm6gr8NtC3a2poj5wqohUHP28iaF0xiGoaWJkjU/NJ73hOINwUzVXv4Gb0TZk7KyC8xICmfa5PM0S+p1MxcsEC5ZRHDdEFQfhzYp1HIZ/cS0a1t/zl5ZnDUBW2GGBQdcUZG3i4KpcsL2ecIGshyirgJVdlVxl5RNW+UWvqbRu3oeqGF6g6pkEsi9VTWtcqqryFgVDVdnpI1JVOuIQVEVIMwfBqGONwE1s9wy5Cq10SatpyVY0Zz/aR4fZqNHRetq2r4vha6zlnB3m0l2wrdlRjmncQE9pbjGX3aDru0N7wOsyWGo2/6d49spAPzOuluLii84Mt3fAwvWeLoNqkNU6pDlwmDTMHoEfeodNknQd7HixyJBCun8pc5t7TxxNrRGrDpwY7QTHUCQ4enO+f02Go1tnA/jd0NOWS95zxfz+OaPJz5akmo1Nn3IWdI8kVc6RPm76Xx1B5fS/uiSAhh5UO6tGMTz5Noe9q/NVcFZw8uUmvhxerJ27+QL5Z8kLpe2HAbtRzzqGofwTVDU6KPPhRYhdzkJwPQs5hz0Ovjb21pR+Z+ncfJ8SPkv/N+3KvwcaiXS7kXOqRyKooJgxBMX67lx3ohFSbEEPvhXT+RNlFlbfMbHnHD98l87nsDm3Cs5BZXNuueoZ0v3ZM9gWsDbB0MC30aZczDNHAAy6uM97ETCwI2DUedWMLnrlVQ/EFdRcwFNtjYxFFTzKZu5Ih1jaywCNiTyCo03koYJNHzWETlm7TP2fcxg19brNsKZwd6xwd3sId79uNndu1+F3mpGfcMvBdhqZ0D23HAyoSBLygzuuxScongs8+NevirNATWtc8PUg3RXHbZfRgcdHbXkJqpaXcicZRsO4MV/W25mmPVKMGE7PIaGcLpcX3Y5IntMf2YYuP9d8ehmTzCPe4fy5InzkWeXOg5LxuKctWbCMe9xSHgXou4p1dgF5qKOa3bh5p7XjbsEs42OQtB5bwgNvPewxxha9ocjquenYpMupHKjsco7y86DXMRuLPOOhl12e/jiisM/pL0yQ/z8= -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | // We suggest you to commit this file into source control 3 | declare global { 4 | const ElTable: typeof import('element-plus/es')['ElTable'] 5 | const ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] 6 | } 7 | export {} 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | plugins: [ 4 | [ 5 | 'import', 6 | { 7 | libraryName: 'vant', 8 | libraryDirectory: 'es', 9 | style: true 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // git commit 规范 2 | // <类型>[可选的作用域]: <描述> 3 | 4 | // # 主要type 5 | // feat: 增加新功能 6 | // fix: 修复bug 7 | 8 | // # 特殊type 9 | // docs: 只改动了文档相关的内容 10 | // style: 不影响代码含义的改动,例如去掉空格、改变缩进、增删分号 11 | // build: 构造工具的或者外部依赖的改动,例如webpack,npm 12 | // refactor: 代码重构时使用 13 | // revert: 执行git revert打印的message 14 | 15 | // # 暂不使用type 16 | // test: 添加测试或者修改现有测试 17 | // perf: 提高性能的改动 18 | // ci: 与CI(持续集成服务)有关的改动 19 | // chore: 不修改src或者test的其余修改,例如构建过程或辅助工具的变动 20 | 21 | module.exports = { 22 | extends: ['@commitlint/config-conventional'], 23 | rules: { 24 | 'body-leading-blank': [2, 'always'], 25 | 'footer-leading-blank': [1, 'always'], 26 | 'header-max-length': [2, 'always', 108], 27 | 'subject-empty': [2, 'never'], 28 | 'type-empty': [2, 'never'], 29 | 'type-enum': [ 30 | 2, 31 | 'always', 32 | [ 33 | 'feat', 34 | 'fix', 35 | 'perf', 36 | 'style', 37 | 'docs', 38 | 'test', 39 | 'refactor', 40 | 'build', 41 | 'ci', 42 | 'chore', 43 | 'revert', 44 | 'wip', 45 | 'workflow', 46 | 'types', 47 | 'release' 48 | ] 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/vue-next/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | declare module '@vue/runtime-core' { 7 | export interface GlobalComponents { 8 | BreadCrumb: typeof import('./src/components/BreadCrumb.vue')['default'] 9 | DocPopover: typeof import('./src/components/DocPopover.vue')['default'] 10 | ElTable: typeof import('element-plus/es')['ElTable'] 11 | ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] 12 | Map: typeof import('./src/components/map/Map.vue')['default'] 13 | Map1: typeof import('./src/components/map/Map1.vue')['default'] 14 | MapBar: typeof import('./src/components/map/MapBar.vue')['default'] 15 | MapOpPopover: typeof import('./src/components/map/MapOpPopover.vue')['default'] 16 | MapRender: typeof import('./src/components/map/MapRender.vue')['default'] 17 | Note: typeof import('./src/components/note/Note.vue')['default'] 18 | NotePopover: typeof import('./src/components/note/NotePopover.vue')['default'] 19 | ProfilePopover: typeof import('./src/components/ProfilePopover.vue')['default'] 20 | Qrcode: typeof import('./src/components/Qrcode.vue')['default'] 21 | RouterLink: typeof import('vue-router')['RouterLink'] 22 | RouterView: typeof import('vue-router')['RouterView'] 23 | Sider: typeof import('./src/components/Sider.vue')['default'] 24 | SvgIcon: typeof import('./src/components/SvgIcon.vue')['default'] 25 | } 26 | } 27 | 28 | export {} 29 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": [ 6 | "src/*" 7 | ], 8 | "assets/*": [ 9 | "src/assets/*" 10 | ], 11 | "components/*": [ 12 | "src/components/*" 13 | ], 14 | "hooks/*": [ 15 | "src/hooks/*" 16 | ], 17 | "store/*": [ 18 | "src/store/*" 19 | ], 20 | "views/*": [ 21 | "src/views/*" 22 | ] 23 | }, 24 | }, 25 | "include": [ 26 | "src/**/*" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } -------------------------------------------------------------------------------- /note.md: -------------------------------------------------------------------------------- 1 | ### 出现的问题记录 2 | > vue3.x:route路径相同参数不同页面不刷新解决 3 | https://blog.csdn.net/AIB_Kasic/article/details/122985366 4 | 5 | > import router from '@/router/index' 6 | **useRoute, useRouter**必须写到setup中,详见vue-next-router.强行在函数中使用这两会报undefined,导致无法获取路由数据和路由方法 7 | 8 | > **for in** & **for of** 9 | - for in遍历的是数组的索引(即键名)。 10 | 而for of遍历的是数组元素值。 11 | - for in 是es5中有的,for of是es6的 12 | 13 | - for-in是为遍历对象而设计的,不适用于遍历数组。它可以正确响应break、continue和return语句 14 | for-in遍历数组的缺点: 15 | 因为for-in遍历的index值"0","1","2"等是字符串而不是数字 16 | for-in循环存在缺陷:会遍历对象自身的和继承的可枚举属性(不含Symbol属性) 17 | 18 | - for-of这个方法避开了for-in循环的所有缺陷 19 | 适用遍历数/数组对象/字符串/map/set等拥有迭代器对象的集合 20 | 它可以正确响应break、continue和return语句 21 | 22 | **for in 得到的数组下标是字符串形式的** 23 | 24 | > vuex state持久化——vuex-persistedstate 25 | 26 | > 使用svg:https://blog.csdn.net/qq_37059838/article/details/108980970 27 | 如果需要修改svg的颜色,svg文件中的填充色fill必须删除 28 | 29 | > vue div contenteditable属性,模拟v-model双向数据绑定功能 30 | `
31 | ` 32 | 33 | > TODO 数据更新后取消默认收起 34 | https://blog.csdn.net/qq_52151772/article/details/119756511 35 | 36 | > TODO 右键菜单 37 | https://www.cnblogs.com/gaofz/p/11995001.html 38 | 39 | > TODO 头像上传裁剪 40 | https://www.cnblogs.com/eightFlying/p/cropper-demo.html 41 | 42 | ### chunk-vendors太大了怎么办? 43 | - splitChunks:elementUI单独打包900kb--->300kb 44 | - publicPath: 'cdn' ----->干扰了路由:https://blog.csdn.net/weixin_29491885/article/details/119253898 45 | 解决:修改createWebHistory() 46 | 47 | ### 打包构建优化 48 | - 缩小文件检索解析范围 添加别名 避免无用的检索与递归遍历,使用alias指定引用时候的模块 49 | `config.resolve.alias.set('@', getAliasPath('src'))` 50 | 51 | ### Map数据流向修改 52 | - edit页集中维护 ---> map.store集中维护 53 | - 流向如何 54 | 55 | ### 优化措施 56 | - v-show替换v-if ---> 导图与Note切换时 57 | - svg相关 58 | - 封装SvgIcon组件 传入icon="name"属性方便使用 59 | - main.js 一次性引入所有svg图标 不用单独引入 60 | - svgo-loader 压缩了svg冗余内容 减小体积 61 | - 网络传输相关 62 | - DNS Prefetch 63 | ```js 64 | 65 | 66 | 67 | ``` 68 | 69 | ### css书写顺序 70 | https://markdotto.com/2011/11/29/css-property-order/ 71 | (1)定位属性:position display float left top right bottom overflow clear z-index 72 | 73 | (2)自身属性:width height padding border margin background 74 | 75 | (3)文字样式:font-family font-size font-style font-weight font-varient color 76 | 77 | (4)文本属性:text-align vertical-align text-wrap text-transform text-indent text-decoration letter-spacing word-spacing white-space text-overflow 78 | 79 | (5)css3中新增属性:content box-shadow border-radius transform…… -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ZMindMap", 3 | "version": "0.2.3", 4 | "author": "zyascend@qq.com", 5 | "private": true, 6 | "scripts": { 7 | "serve": "vue-cli-service serve", 8 | "build": "vue-cli-service build", 9 | "report": "vue-cli-service build --report", 10 | "lint": "vue-cli-service lint", 11 | "inspect:prod": "vue-cli-service inspect > output_prod.js --mode production", 12 | "inspect:dev": "vue-cli-service inspect > output_dev.js --mode development", 13 | "stylelint:fix": "stylelint **/*.{vue,css,scss} --fix", 14 | "postinstall": "husky install", 15 | "commitlint": "commitlint --from=master", 16 | "lint:prettier": "npx prettier --write src/" 17 | }, 18 | "lint-staged": { 19 | "**/*.{scss}": [ 20 | "npm run stylelint:fix", 21 | "git add --force" 22 | ] 23 | }, 24 | "dependencies": { 25 | "@sentry/tracing": "^6.19.7", 26 | "@sentry/vue": "^6.19.7", 27 | "axios": "^0.26.1", 28 | "core-js": "^3.6.5", 29 | "cropperjs": "^1.5.12", 30 | "d3-hierarchy": "^3.0.1", 31 | "d3-selection": "^3.0.0", 32 | "d3-shape": "^3.0.1", 33 | "d3-transition": "^3.0.1", 34 | "d3-zoom": "^3.0.0", 35 | "element-plus": "^2.1.11", 36 | "js-md5": "^0.7.3", 37 | "nanoid": "^3.3.3", 38 | "pinia": "^2.0.13", 39 | "pinia-plugin-persist": "^1.0.0", 40 | "save-svg-as-png": "^1.4.17", 41 | "socket.io-client": "^4.5.1", 42 | "vue": "^3.2.31", 43 | "vue-qr": "^4.0.6", 44 | "vue-router": "^4.0.0-0", 45 | "xss": "^1.0.11" 46 | }, 47 | "devDependencies": { 48 | "@commitlint/cli": "^16.2.4", 49 | "@commitlint/config-conventional": "^16.2.4", 50 | "@sentry/webpack-plugin": "^1.18.9", 51 | "@types/js-md5": "^0.4.3", 52 | "@vue/cli-plugin-babel": "~4.5.0", 53 | "@vue/cli-plugin-eslint": "~4.5.0", 54 | "@vue/cli-plugin-router": "~4.5.0", 55 | "@vue/cli-plugin-vuex": "~4.5.0", 56 | "@vue/cli-service": "~4.5.0", 57 | "@vue/compiler-sfc": "^3.0.0", 58 | "@vue/eslint-config-standard": "^5.1.2", 59 | "babel-eslint": "^10.1.0", 60 | "babel-plugin-import": "^1.13.5", 61 | "compression-webpack-plugin": "^5.0.2", 62 | "esbuild-loader": "^2.19.0", 63 | "eslint": "^7.32.0", 64 | "eslint-config-airbnb-base": "^15.0.0", 65 | "eslint-config-prettier": "^8.5.0", 66 | "eslint-plugin-import": "^2.20.2", 67 | "eslint-plugin-node": "^11.1.0", 68 | "eslint-plugin-prettier": "^4.2.1", 69 | "eslint-plugin-promise": "^4.2.1", 70 | "eslint-plugin-standard": "^4.0.0", 71 | "eslint-plugin-vue": "^8.7.1", 72 | "hard-source-webpack-plugin": "^0.13.1", 73 | "husky": "^8.0.1", 74 | "lint-staged": "^12.4.1", 75 | "node-sass": "^7.0.1", 76 | "pre-commit": "^1.2.2", 77 | "prettier": "^2.7.1", 78 | "sass": "^1.26.5", 79 | "sass-loader": "^8.0.2", 80 | "speed-measure-webpack-plugin": "^1.5.0", 81 | "stylelint": "^13.13.1", 82 | "stylelint-config-prettier": "^8.0.2", 83 | "stylelint-config-recess-order": "^2.4.0", 84 | "stylelint-config-standard": "^22.0.0", 85 | "svg-sprite-loader": "^6.0.11", 86 | "svgo-loader": "^3.0.0", 87 | "unplugin-auto-import": "^0.7.1", 88 | "unplugin-element-plus": "^0.4.0", 89 | "unplugin-vue-components": "^0.22.4", 90 | "worker-loader": "^3.0.8" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%= htmlWebpackPlugin.options.title %> 14 | 37 | 47 | 48 | 49 | 52 |
53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /src/assets/css/handler.scss: -------------------------------------------------------------------------------- 1 | @import "./themes.scss"; 2 | @import "./mixin"; 3 | 4 | //遍历主题map 5 | @mixin themeify { 6 | @each $theme-name, $theme-map in $themes { 7 | // !global 把局部变量强升为全局变量 8 | $theme-map: $theme-map !global; 9 | //判断html的data-theme的属性值 #{}是sass的插值表达式 10 | //& sass嵌套里的父容器标识 @content是混合器插槽,像vue的slot 11 | [data-theme="#{$theme-name}"] & { 12 | @content; 13 | } 14 | } 15 | } 16 | 17 | //声明一个根据Key获取颜色的function 18 | @function themed($key) { 19 | @return map-get($theme-map, $key); 20 | } 21 | 22 | //获取背景颜色 23 | @mixin background_color($color) { 24 | @include themeify { 25 | background-color: themed($color) !important; 26 | } 27 | } 28 | 29 | //获取字体颜色 30 | @mixin font_color($color) { 31 | @include themeify { 32 | color: themed($color) !important; 33 | } 34 | } 35 | 36 | //获取字体颜色 37 | @mixin fill_color($color) { 38 | @include themeify { 39 | fill: themed($color) !important; 40 | } 41 | } 42 | 43 | //获取边框颜色 44 | @mixin border_color($color) { 45 | @include themeify { 46 | border-color: themed($color) !important; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/assets/css/mixin.scss: -------------------------------------------------------------------------------- 1 | @mixin fullFixed { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | } 8 | 9 | @mixin wh100 { 10 | width: 100%; 11 | height: 100%; 12 | } 13 | 14 | @mixin vertFlex { 15 | display: flex; 16 | flex-direction: column; 17 | } 18 | 19 | @mixin horiFlex { 20 | display: flex; 21 | flex-direction: row; 22 | } 23 | 24 | @mixin centerFlex { 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | } 29 | 30 | @mixin wh($w, $h) { 31 | width: $w; 32 | height: $h; 33 | } 34 | 35 | @mixin abs-center { 36 | position: absolute; 37 | top: 50%; 38 | left: 50%; 39 | transform: translate(-50%, -50%); 40 | } 41 | $color-base: #5856d5 42 | -------------------------------------------------------------------------------- /src/assets/css/reset.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable comment-empty-line-before */ 2 | html, 3 | body, 4 | div, 5 | span, 6 | applet, 7 | object, 8 | iframe, 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6, 15 | p, 16 | blockquote, 17 | pre, 18 | a, 19 | abbr, 20 | acronym, 21 | address, 22 | big, 23 | cite, 24 | code, 25 | del, 26 | dfn, 27 | em, 28 | img, 29 | ins, 30 | kbd, 31 | q, 32 | s, 33 | samp, 34 | small, 35 | strike, 36 | strong, 37 | sub, 38 | sup, 39 | tt, 40 | var, 41 | b, 42 | u, 43 | i, 44 | center, 45 | dl, 46 | dt, 47 | dd, 48 | ol, 49 | ul, 50 | li, 51 | fieldset, 52 | form, 53 | label, 54 | legend, 55 | table, 56 | caption, 57 | tbody, 58 | tfoot, 59 | thead, 60 | tr, 61 | th, 62 | td, 63 | article, 64 | aside, 65 | canvas, 66 | details, 67 | embed, 68 | figure, 69 | figcaption, 70 | footer, 71 | header, 72 | hgroup, 73 | menu, 74 | nav, 75 | output, 76 | ruby, 77 | section, 78 | summary, 79 | time, 80 | mark, 81 | audio, 82 | video { 83 | box-sizing: border-box; 84 | padding: 0; 85 | margin: 0; 86 | font: inherit; 87 | font-size: 100%; 88 | vertical-align: baseline; 89 | user-select: none; 90 | border: 0; 91 | outline: 0; 92 | } 93 | 94 | /* HTML5 display-role reset for older browsers */ 95 | article, 96 | aside, 97 | details, 98 | figcaption, 99 | figure, 100 | footer, 101 | header, 102 | hgroup, 103 | menu, 104 | nav, 105 | section { 106 | display: block; 107 | } 108 | 109 | ol, 110 | ul { 111 | list-style: none; 112 | } 113 | 114 | blockquote, 115 | q { 116 | quotes: none; 117 | } 118 | 119 | blockquote::before, 120 | blockquote::after, 121 | q::before, 122 | q::after { 123 | content: ''; 124 | content: none; 125 | } 126 | 127 | table { 128 | border-spacing: 0; 129 | border-collapse: collapse; 130 | } 131 | 132 | body { 133 | width: 100%; 134 | height: 100%; 135 | /* overflow: hidden; */ 136 | line-height: 1; 137 | } 138 | 139 | html { 140 | width: 100%; 141 | height: 100%; 142 | /* overflow: hidden; */ 143 | } 144 | 145 | a, 146 | button { 147 | text-decoration: none; 148 | cursor: pointer; 149 | } 150 | input:-webkit-autofill, 151 | input:-webkit-autofill:hover, 152 | input:-webkit-autofill:focus, 153 | input:-webkit-autofill:active { 154 | -webkit-transition-delay: 111111s; 155 | -webkit-transition: color 11111s ease-out, background-color 111111s ease-out; 156 | } 157 | /* div::-webkit-scrollbar { width: 0 !important } */ 158 | -------------------------------------------------------------------------------- /src/assets/css/themes.scss: -------------------------------------------------------------------------------- 1 | $themes: ( 2 | light: ( 3 | //字体 4 | fc_pop: #1d1d1f, 5 | fc_normal: #1d1d1f, 6 | fc_nickname: #1d1d1f, 7 | fc_girdtable: #1d1d1f, 8 | fc_bread_active: #1d1d1f, 9 | fc_side_link: #75757d, 10 | fc_tree_node: #75757d, 11 | fc_side_link_hover: #5856d5, 12 | fc_side_link_active: #5856d5, 13 | fc_input: #92929c, 14 | fc_grid: #2c2c2f, 15 | fc_bread: #92929c, 16 | //背景 17 | bc_sidebar: #f4f4f5, 18 | bc_content: #fff, 19 | bc_collapser: #fff, 20 | bc_divider: #e9e9eb, 21 | bc_popover: #fff, 22 | bc_hover_nickname: #e9e9eb, 23 | bc_griditem_hover: #f4f4f5, 24 | bc_td_hover: #f5f7fa, 25 | bc_side_link_hover: #deddf7, 26 | bc_side_link_active: #deddf7, 27 | bc_tree_node: #f5f7fa, 28 | bc_tree_node_hover: #e9e9eb, 29 | bc_pop_hover: #f7f7f7, 30 | bc_input: #e9e9eb, 31 | bc_adder: #2c2c2f, 32 | bc_dialog_left: #f4f4f5, 33 | bc_dialog_right: #fff, 34 | bc_avatar_btn_hover:#f8f8fd, 35 | // border 36 | bdc_grid: #dedee1, 37 | bdc_show_btn: #c9c9ce, 38 | bdc_table_divider: #dedee1, 39 | bdc_dialog_left: #dedee1, 40 | ), 41 | dark: ( 42 | //字体 43 | fc_normal: #f4f4f5, 44 | fc_pop: #f4f4f5, 45 | fc_nickname: #f4f4f5, 46 | fc_bread_active: #f4f4f5, 47 | fc_side_link_hover: #f4f4f5, 48 | fc_side_link_active: #f4f4f5, 49 | fc_bread: #9d9da6, 50 | fc_input: #9d9da6, 51 | fc_grid: #fff, 52 | fc_girdtable: #c9c9ce, 53 | fc_side_link: #c9c9ce, 54 | fc_tree_node: #dedee1, 55 | //背景 56 | bc_sidebar: #2c2c2f, 57 | bc_content: #1d1d1f, 58 | bc_collapser: #1d1d1f, 59 | bc_divider: #66666d, 60 | bc_popover: #49494e, 61 | bc_hover_nickname: transparent, 62 | bc_griditem_hover: #49494e80, 63 | bc_td_hover: #49494e80, 64 | bc_side_link_hover: #5856d5, 65 | bc_side_link_active: #5856d5, 66 | bc_tree_node: #f5f7fa, 67 | bc_tree_node_hover: #49494e, 68 | bc_pop_hover: #ffffff0f, 69 | bc_input: #49494e, 70 | bc_adder: #5856d5, 71 | bc_dialog_left: #2c2c2f, 72 | bc_dialog_right: #49494e, 73 | bc_avatar_btn_hover: #25245b, 74 | // border 75 | bdc_grid: transparent, 76 | bdc_show_btn: #696a6b, 77 | bdc_table_divider: #66666d, 78 | bdc_dialog_left: #66666d, 79 | ), 80 | ); 81 | -------------------------------------------------------------------------------- /src/assets/map/add.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/map/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/map/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/pic/404.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/assets/pic/add-file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/add-quick.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/add.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/pic/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pic/arrow-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/src/assets/pic/empty.png -------------------------------------------------------------------------------- /src/assets/pic/file-large.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/assets/pic/file-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/assets/pic/fit-view.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/pic/folder-large.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/assets/pic/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/assets/pic/grid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/hamberger.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/latest.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/pic/logout.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/marker.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/more.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/note.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/pc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/src/assets/pic/pc.png -------------------------------------------------------------------------------- /src/assets/pic/qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/src/assets/pic/qrcode.png -------------------------------------------------------------------------------- /src/assets/pic/quick.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/src/assets/pic/refresh.png -------------------------------------------------------------------------------- /src/assets/pic/remove.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/rename.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/search.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/pic/settings.svg: -------------------------------------------------------------------------------- 1 | icon_web_accountsetting_20 -------------------------------------------------------------------------------- /src/assets/pic/skin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyascend/ZMindMap/54c169b9d6293502aefdeabf29e9cc31c5e988c0/src/assets/pic/success.png -------------------------------------------------------------------------------- /src/assets/pic/table.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/trash.svg: -------------------------------------------------------------------------------- 1 | icon_web_recycle_20_fill -------------------------------------------------------------------------------- /src/assets/pic/tree.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/triangle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pic/upload.svg: -------------------------------------------------------------------------------- 1 | icon_web_upload -------------------------------------------------------------------------------- /src/assets/pic/workwith.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/DocPopover.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 155 | 156 | 211 | -------------------------------------------------------------------------------- /src/components/SvgIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 33 | 34 | 40 | -------------------------------------------------------------------------------- /src/components/map/Map-beta.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 57 | -------------------------------------------------------------------------------- /src/components/map/Map.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 41 | -------------------------------------------------------------------------------- /src/components/map/MapBar.vue: -------------------------------------------------------------------------------- 1 | 96 | 97 | 142 | 143 | 266 | -------------------------------------------------------------------------------- /src/components/map/MapOpPopover.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 72 | 73 | 124 | -------------------------------------------------------------------------------- /src/components/note/NotePopover.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 92 | 93 | 174 | -------------------------------------------------------------------------------- /src/hooks/http/apis.js: -------------------------------------------------------------------------------- 1 | const apis = { 2 | register: '/users/register', 3 | login: '/users/login', 4 | editProfile: 'users/editProfile', 5 | getCurrentUser: 'users/getUser', 6 | 7 | getCode: '/code/generate', 8 | getCodeStatus: '/code/getStatus', 9 | 10 | getAllDocs: 'docs/getAllDocs', 11 | getDocContent: 'docs/getDocContent', 12 | setFolder: 'docs/setFolder', 13 | setDoc: 'docs/setDoc', 14 | setDocContent: 'docs/setDocContent', 15 | remove: 'docs/remove', 16 | uploadImg: 'docs/uploadImg', 17 | 18 | getStyles: 'styles' 19 | } 20 | 21 | export default apis 22 | -------------------------------------------------------------------------------- /src/hooks/http/axios.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import axios from 'axios' 3 | import { ErrorTip } from '@/hooks/utils' 4 | 5 | /** 6 | * 跳转登录页 7 | * 携带当前页面路由,在登录页面完成登录后返回当前页面 8 | */ 9 | const toLoginPage = () => { 10 | localStorage.clear() 11 | window.location.replace(`/login?redirect=${window.location.pathname}`) 12 | } 13 | 14 | const errorMap = { 15 | 401: toLoginPage, 16 | 403: () => ErrorTip('操作失败 无权限'), 17 | 404: msg => { 18 | ErrorTip(msg?.error || '【404】找不到资源') 19 | } 20 | } 21 | 22 | /** 23 | * 请求失败后的错误统一处理 24 | * @param {Number} status 请求失败的状态码 25 | */ 26 | const errorHandle = (status, msg) => { 27 | // 状态码判断 28 | const handler = errorMap[status] 29 | if (handler) handler(msg) 30 | else { 31 | ErrorTip('系统错误 请稍后再试') 32 | } 33 | } 34 | 35 | // 创建axios实例 36 | const isPrd = process.env.NODE_ENV === 'production' 37 | const { apiCfg } = window.CFG 38 | const instance = axios.create({ 39 | timeout: 1000 * 12, 40 | baseURL: isPrd ? apiCfg.prdHost : apiCfg.devHost 41 | }) 42 | // 设置post请求头 43 | instance.defaults.headers.post['Content-Type'] = 'application/json' 44 | /** 45 | * 请求拦截器 46 | * 每次请求前,如果存在token则在请求头中携带token 47 | */ 48 | instance.interceptors.request.use( 49 | config => { 50 | const token = localStorage.getItem('token') || '' 51 | token && (config.headers.Authorization = `Bearer ${token}`) 52 | return config 53 | }, 54 | error => Promise.error(error) 55 | ) 56 | 57 | // 响应拦截器 58 | instance.interceptors.response.use( 59 | // 请求成功 60 | res => (res.status === 200 ? Promise.resolve(res.data) : Promise.reject(res)), 61 | // 请求失败 62 | error => { 63 | const { response } = error 64 | if (response) { 65 | // 请求已发出,但是不在2xx的范围 66 | errorHandle(response.status, response.data) 67 | return Promise.reject(response) 68 | } 69 | // TODO 处理断网的情况 70 | return Promise.reject(error) 71 | } 72 | ) 73 | 74 | export default instance 75 | -------------------------------------------------------------------------------- /src/hooks/http/index.js: -------------------------------------------------------------------------------- 1 | import axios from './axios' 2 | import apis from './apis' 3 | 4 | const asyncHttp = async ( 5 | url, 6 | config = { method: 'get' }, 7 | extraData = undefined 8 | ) => { 9 | const [error, result] = await axios(url, config) 10 | .then(res => [null, res]) 11 | .catch(err => [err, null]) 12 | if (error || !result) { 13 | return null 14 | } 15 | return result.data 16 | } 17 | 18 | const withUserIdUrl = url => { 19 | const { user } = JSON.parse(localStorage.getItem('zmindmap_user')) 20 | return `${url}/${user?._id}` 21 | } 22 | 23 | const normalGet = async (url, withUserId = true) => { 24 | let res 25 | if (withUserId) { 26 | res = await asyncHttp(withUserIdUrl(url)) 27 | } else { 28 | res = await asyncHttp(url) 29 | } 30 | return res 31 | } 32 | 33 | const normalPost = async (url, data, withUserId = true) => { 34 | let requestUrl 35 | if (withUserId) { 36 | requestUrl = withUserIdUrl(url) 37 | } else { 38 | requestUrl = url 39 | } 40 | const res = await asyncHttp(requestUrl, { 41 | method: 'post', 42 | data 43 | }) 44 | return res 45 | } 46 | 47 | export const docApi = { 48 | fetchAllDocuments: async () => { 49 | const res = await normalGet(apis.getAllDocs) 50 | return res 51 | }, 52 | postSetFolder: async data => { 53 | const res = await normalPost(apis.setFolder, data) 54 | return res 55 | }, 56 | postSetDoc: async data => { 57 | const res = await normalPost(apis.setDoc, data) 58 | return res 59 | }, 60 | postRemove: async data => { 61 | const res = await normalPost(apis.remove, data) 62 | return res 63 | } 64 | } 65 | export const mapApi = { 66 | fetchMap: async docId => { 67 | const url = `${withUserIdUrl(apis.getDocContent)}/${docId}` 68 | const res = await normalGet(url, false) 69 | return res 70 | }, 71 | remoteUpdateMap: async data => { 72 | const res = await normalPost(apis.setDocContent, data) 73 | return res 74 | }, 75 | uploadImg: async config => { 76 | const res = await asyncHttp(withUserIdUrl(apis.uploadImg), { 77 | method: 'post', 78 | ...config 79 | }) 80 | return res 81 | } 82 | } 83 | 84 | export const userApi = { 85 | login: async data => { 86 | const res = await normalPost(apis.login, data, false) 87 | return res 88 | }, 89 | register: async data => { 90 | const res = await normalPost(apis.register, data, false) 91 | return res 92 | }, 93 | updateUser: async config => { 94 | const res = await asyncHttp(withUserIdUrl(apis.editProfile), { 95 | method: 'post', 96 | ...config 97 | }) 98 | return res 99 | }, 100 | generateCode: async () => { 101 | const res = await normalGet(apis.getCode, false) 102 | return res 103 | }, 104 | getCodeStatus: async qid => { 105 | const res = await normalGet(`${apis.getCodeStatus}?qid=${qid}`, false) 106 | return res 107 | } 108 | } 109 | 110 | export const websiteApi = { 111 | fetchMapStyles: async () => { 112 | const res = await normalGet(apis.getStyles, false) 113 | return res 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/hooks/map.worker.js: -------------------------------------------------------------------------------- 1 | import md5 from 'js-md5' 2 | 3 | onmessage = function (e) { 4 | const { source, type } = e.data 5 | let result 6 | switch (type) { 7 | case 'MD5': 8 | result = md5(source) 9 | break 10 | default: 11 | break 12 | } 13 | postMessage({ type, result }) 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/map/LogicTree-beta.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { linkHorizontal } from 'd3-shape' 3 | import Tree from './Tree' 4 | 5 | export default class LogicTree extends Tree { 6 | constructor(nodeMap) { 7 | super() 8 | this.defaultWidth = 30 9 | this.maxWidth = 250 10 | this.defaultHeight = 40 11 | this.defaultRootHeight = 60 12 | this.padding = 10 13 | this.defaultMarkerHeight = 18 14 | this.defaultMarkerWidth = 18 15 | this.markerOverlap = 7 16 | this.textMarkersGap = 10 17 | this.gapY = 20 18 | this.gapX = 40 19 | this.rectRadius = 5 20 | this.strokeWidth = 0 21 | 22 | this.map = nodeMap 23 | console.log('map ', this.map) 24 | 25 | this.bézierCurveGenerator = linkHorizontal() 26 | .x(d => d.x) 27 | .y(d => d.y) 28 | } 29 | 30 | create(root) { 31 | console.time('TIME WH') 32 | this.measureWidthAndHeight(root) 33 | console.timeEnd('TIME WH') 34 | console.time('TIME XY') 35 | this.calculateXY(root) 36 | console.timeEnd('TIME XY') 37 | console.time('TIME PATH') 38 | const paths = this.calculatePath(root) 39 | console.timeEnd('TIME PATH') 40 | return { 41 | paths, 42 | root 43 | } 44 | } 45 | 46 | measureWidthAndHeight(root) { 47 | // 后续遍历 初步计算 父依赖于子 48 | root.eachAfter(node => { 49 | const { id } = node.data 50 | const preNode = this.map.get(id) 51 | if (!preNode) { 52 | console.log('新增的节点') 53 | // 新增的节点 54 | if (node.parent) { 55 | node.parent.data.patchFlags = 1 56 | } 57 | this.measureTextSize(node) 58 | this.measureMarkers(node) 59 | this.measureWH(node) 60 | } else { 61 | // 是否需要重新计算 62 | const flag = root.data?.patchFlags ?? 0 63 | const needUpdate = flag > 0 64 | if (needUpdate) { 65 | console.log('重新计算 > ', root.data.name) 66 | // 需要 那么父节点也需要 67 | if (node.parent) { 68 | node.parent.data.patchFlags = 1 69 | } 70 | // 重新计算 71 | this.measureTextSize(node) 72 | this.measureMarkers(node) 73 | this.measureWH(node) 74 | } else { 75 | // 不需要重新计算 76 | console.log('不需要重新计算 > ', root.data.name) 77 | root = preNode 78 | } 79 | } 80 | }) 81 | } 82 | 83 | measureWH(node) { 84 | node.rectRadius = this.rectRadius 85 | node.strokeWidth = this.strokeWidth 86 | 87 | node.outLineOffset = 0 88 | 89 | const tmGap = node.mw ? this.textMarkersGap : 0 90 | const tiGap = node.ih ? this.textMarkersGap : 0 91 | node.cw = Math.max( 92 | Math.max(node.tw, node.iw) + node.mw + this.padding * 2 + tmGap, 93 | this.defaultWidth 94 | ) 95 | node.ch = Math.max( 96 | this.padding * 2 + node.ih + tiGap + node.th, 97 | this.defaultHeight 98 | ) 99 | const { children } = node 100 | if (!children) { 101 | node.w = node.cw 102 | node.h = node.ch 103 | } else { 104 | const maxW = Math.max(...children.map(c => c.w)) 105 | const sumH = children.reduce((p, c) => p + c.h, 0) 106 | node.h = sumH + this.gapY * (children.length - 1) 107 | node.w = node.cw + this.gapX + maxW 108 | } 109 | 110 | node.outLineW = node.cw - node.outLineOffset * 2 111 | node.outLineH = node.ch - node.outLineOffset * 2 112 | } 113 | 114 | calculateInnerXY(node) { 115 | const { mw, th, mh, ch } = node 116 | node.mx = this.padding 117 | node.tx = node.mx + mw + (mw ? this.textMarkersGap : 0) 118 | node.ty = ch - this.padding - th - 4 119 | node.my = node.ty + th / 2 - mh / 2 + 4 120 | 121 | node.ix = node.tx 122 | node.iy = this.padding 123 | } 124 | 125 | calculateXY(root) { 126 | let anchor 127 | // 前序遍历 计算X 128 | root.eachBefore(node => { 129 | this.calculateInnerXY(node) 130 | const { depth } = node 131 | if (depth === 0) { 132 | node.x = 140 133 | anchor = node 134 | return 135 | } 136 | const { depth: lastDepth, cw, x } = anchor 137 | if (depth === lastDepth) { 138 | node.x = x 139 | } else if (depth > lastDepth) { 140 | node.x = x + cw + this.gapX 141 | } else { 142 | const bro = this.findPrevBrother(node) 143 | node.x = bro.x 144 | } 145 | anchor = node 146 | }) 147 | // 后序遍历 计算Y 148 | anchor = undefined 149 | root.eachAfter(node => { 150 | const { depth } = node 151 | if (!anchor) { 152 | node.y = 100 153 | anchor = node 154 | return 155 | } 156 | const { depth: lastDepth, ch, y } = anchor 157 | if (depth < lastDepth) { 158 | const firstChild = node.children[0] 159 | node.y = firstChild.y + (y - firstChild.y + ch) / 2 - node.ch / 2 160 | // ![BUG]父节点很高 超过了第一个子节点 161 | if (node.y - node.ch / 2 < firstChild.y - firstChild.ch / 2) { 162 | // !TODO 还需要递归的处理该子节点的所有子代 163 | // !F**K 麻烦啊 164 | node.y = firstChild.y + 1 165 | } 166 | } else { 167 | const bottom = this.findBottom(anchor) 168 | node.y = Math.max(bottom.y + bottom.ch + this.gapY, y + ch + this.gapY) 169 | } 170 | anchor = node 171 | }) 172 | } 173 | 174 | /** 175 | * 在已经计算过的节点中寻找与之最靠近的节点 176 | * @param {*} node 177 | * @returns 178 | */ 179 | findBottom(node) { 180 | let bottom = node 181 | while (bottom?.children) { 182 | bottom = bottom.children[bottom.children.length - 1] 183 | } 184 | return bottom 185 | } 186 | 187 | calculatePath(root) { 188 | const links = root.links() 189 | const paths = links.map(l => this.getPathData(l)) 190 | return paths 191 | } 192 | 193 | getPathData(link) { 194 | const { source, target } = link 195 | const { x: sx, y: sy, cw, ch: sh, id: sid } = source 196 | const { x: tx, y: ty, ch, id: tid } = target 197 | // 生成从一个源点到目标点的光滑的三次贝塞尔曲线 198 | const bezierLine = this.bézierCurveGenerator({ 199 | source: { 200 | x: sx + cw, 201 | y: sy + sh / 2 202 | }, 203 | target: { 204 | x: tx, 205 | y: ty + ch / 2 206 | } 207 | }) 208 | return { 209 | data: bezierLine, 210 | id: `path-${sid}-${tid}` 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/hooks/map/LogicTree.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { linkHorizontal } from 'd3-shape' 3 | import Tree from './Tree' 4 | 5 | export default class LogicTree extends Tree { 6 | constructor() { 7 | super() 8 | this.defaultWidth = 30 9 | this.maxWidth = 250 10 | this.defaultHeight = 40 11 | this.defaultRootHeight = 60 12 | this.padding = 10 13 | this.defaultMarkerHeight = 18 14 | this.defaultMarkerWidth = 18 15 | this.markerOverlap = 7 16 | this.textMarkersGap = 10 17 | this.gapY = 20 18 | this.gapX = 40 19 | this.rectRadius = 5 20 | this.strokeWidth = 0 21 | 22 | this.bézierCurveGenerator = linkHorizontal() 23 | .x(d => d.x) 24 | .y(d => d.y) 25 | } 26 | 27 | create(root) { 28 | const tagWH = '[TIME] 计算宽高耗时: ' 29 | const tagXY = '[TIME] 计算位置耗时: ' 30 | const tagPATH = '[TIME] 计算路径耗时: ' 31 | const tagAll = '[TIME] 计算总耗时: ' 32 | console.time(tagAll) 33 | 34 | console.time(tagWH) 35 | this.measureWidthAndHeight(root) 36 | console.timeEnd(tagWH) 37 | 38 | console.time(tagXY) 39 | this.calculateXY(root) 40 | console.timeEnd(tagXY) 41 | 42 | console.time(tagPATH) 43 | const paths = this.calculatePath(root) 44 | console.timeEnd(tagPATH) 45 | 46 | console.timeEnd(tagAll) 47 | return { 48 | paths, 49 | nodes: root.descendants() 50 | } 51 | } 52 | 53 | measureWidthAndHeight(root) { 54 | // 后续遍历 初步计算 父依赖于子 55 | root.eachAfter(node => { 56 | this.measureImageSize(node) 57 | this.measureTextSize(node) 58 | this.measureMarkers(node) 59 | this.measureWH(node) 60 | }) 61 | } 62 | 63 | measureWH(node) { 64 | node.rectRadius = this.rectRadius 65 | node.strokeWidth = this.strokeWidth 66 | 67 | node.outLineOffset = 0 68 | 69 | const tmGap = node.mw ? this.textMarkersGap : 0 70 | const tiGap = node.ih ? this.textMarkersGap : 0 71 | node.cw = Math.max( 72 | Math.max(node.tw, node.iw) + node.mw + this.padding * 2 + tmGap, 73 | this.defaultWidth 74 | ) 75 | node.ch = Math.max( 76 | this.padding * 2 + node.ih + tiGap + node.th, 77 | this.defaultHeight 78 | ) 79 | const { children } = node 80 | if (!children) { 81 | node.w = node.cw 82 | node.h = node.ch 83 | } else { 84 | const maxW = Math.max(...children.map(c => c.w)) 85 | const sumH = children.reduce((p, c) => p + c.h, 0) 86 | node.h = sumH + this.gapY * (children.length - 1) 87 | node.w = node.cw + this.gapX + maxW 88 | } 89 | 90 | node.outLineW = node.cw - node.outLineOffset * 2 91 | node.outLineH = node.ch - node.outLineOffset * 2 92 | } 93 | 94 | calculateInnerXY(node) { 95 | const { mw, th, mh, ch } = node 96 | node.mx = this.padding 97 | node.tx = node.mx + mw + (mw ? this.textMarkersGap : 0) 98 | node.ty = ch - this.padding - th - 4 99 | node.my = node.ty + th / 2 - mh / 2 + 4 100 | 101 | node.ix = node.tx 102 | node.iy = this.padding 103 | } 104 | 105 | calculateXY(root) { 106 | let anchor 107 | // 前序遍历 计算X 108 | root.eachBefore(node => { 109 | this.calculateInnerXY(node) 110 | const { depth } = node 111 | if (depth === 0) { 112 | node.x = 140 113 | anchor = node 114 | return 115 | } 116 | const { depth: lastDepth, cw, x } = anchor 117 | if (depth === lastDepth) { 118 | node.x = x 119 | } else if (depth > lastDepth) { 120 | node.x = x + cw + this.gapX 121 | } else { 122 | const bro = this.findPrevBrother(node) 123 | node.x = bro.x 124 | } 125 | anchor = node 126 | }) 127 | // 后序遍历 计算Y 128 | anchor = undefined 129 | root.eachAfter(node => { 130 | const { depth } = node 131 | if (!anchor) { 132 | node.y = 100 133 | anchor = node 134 | return 135 | } 136 | const { depth: lastDepth, ch, y } = anchor 137 | if (depth < lastDepth) { 138 | const firstChild = node.children[0] 139 | node.y = firstChild.y + (y - firstChild.y + ch) / 2 - node.ch / 2 140 | // ![BUG]父节点很高 超过了第一个子节点 141 | if (node.y - node.ch / 2 < firstChild.y - firstChild.ch / 2) { 142 | // !TODO 还需要递归的处理该子节点的所有子代 143 | // !F**K 麻烦啊 144 | node.y = firstChild.y + 1 145 | } 146 | } else { 147 | const bottom = this.findBottom(anchor) 148 | node.y = Math.max(bottom.y + bottom.ch + this.gapY, y + ch + this.gapY) 149 | } 150 | anchor = node 151 | }) 152 | } 153 | 154 | /** 155 | * 在已经计算过的节点中寻找与之最靠近的节点 156 | * @param {*} node 157 | * @returns 158 | */ 159 | findBottom(node) { 160 | let bottom = node 161 | while (bottom?.children) { 162 | bottom = bottom.children[bottom.children.length - 1] 163 | } 164 | return bottom 165 | } 166 | 167 | calculatePath(root) { 168 | const links = root.links() 169 | const paths = links.map(l => this.getPathData(l)) 170 | return paths 171 | } 172 | 173 | getPathData(link) { 174 | const { source, target } = link 175 | const { x: sx, y: sy, cw, ch: sh, id: sid } = source 176 | const { x: tx, y: ty, ch, id: tid } = target 177 | // 生成从一个源点到目标点的光滑的三次贝塞尔曲线 178 | const bezierLine = this.bézierCurveGenerator({ 179 | source: { 180 | x: sx + cw, 181 | y: sy + sh / 2 182 | }, 183 | target: { 184 | x: tx, 185 | y: ty + ch / 2 186 | } 187 | }) 188 | return { 189 | data: bezierLine, 190 | id: `path-${sid}-${tid}` 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/hooks/map/LogicTree1.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { linkHorizontal } from 'd3-shape' 3 | import Tree from './Tree' 4 | 5 | export default class LogicTree extends Tree { 6 | constructor(nodeMap) { 7 | super() 8 | this.defaultWidth = 30 9 | this.maxWidth = 250 10 | this.defaultHeight = 40 11 | this.defaultRootHeight = 60 12 | this.padding = 10 13 | this.defaultMarkerHeight = 18 14 | this.defaultMarkerWidth = 18 15 | this.markerOverlap = 7 16 | this.textMarkersGap = 10 17 | this.gapY = 20 18 | this.gapX = 40 19 | this.rectRadius = 5 20 | this.strokeWidth = 0 21 | 22 | this.map = nodeMap 23 | console.log('map ', this.map) 24 | 25 | this.bézierCurveGenerator = linkHorizontal() 26 | .x(d => d.x) 27 | .y(d => d.y) 28 | } 29 | 30 | create(root) { 31 | console.time('TIME WH') 32 | this.measureWidthAndHeight(root) 33 | console.timeEnd('TIME WH') 34 | console.time('TIME XY') 35 | this.calculateXY(root) 36 | console.timeEnd('TIME XY') 37 | console.time('TIME PATH') 38 | const paths = this.calculatePath(root) 39 | console.timeEnd('TIME PATH') 40 | return { 41 | paths, 42 | root 43 | } 44 | } 45 | 46 | measureWidthAndHeight(root) { 47 | // 后续遍历 初步计算 父依赖于子 48 | root.eachAfter(node => { 49 | const { id } = node.data 50 | const preNode = this.map.get(id) 51 | if (!preNode) { 52 | console.log('新增的节点') 53 | // 新增的节点 54 | if (node.parent) { 55 | node.parent.data.patchFlags = 1 56 | } 57 | this.measureTextSize(node) 58 | this.measureMarkers(node) 59 | this.measureWH(node) 60 | } else { 61 | // 是否需要重新计算 62 | const flag = root.data?.patchFlags ?? 0 63 | const needUpdate = flag > 0 64 | if (needUpdate) { 65 | console.log('重新计算 > ', root.data.name) 66 | // 需要 那么父节点也需要 67 | if (node.parent) { 68 | node.parent.data.patchFlags = 1 69 | } 70 | // 重新计算 71 | this.measureTextSize(node) 72 | this.measureMarkers(node) 73 | this.measureWH(node) 74 | } else { 75 | // 不需要重新计算 76 | console.log('不需要重新计算 > ', root.data.name) 77 | root = preNode 78 | } 79 | } 80 | }) 81 | } 82 | 83 | measureWH(node) { 84 | node.rectRadius = this.rectRadius 85 | node.strokeWidth = this.strokeWidth 86 | 87 | node.outLineOffset = 0 88 | 89 | const tmGap = node.mw ? this.textMarkersGap : 0 90 | const tiGap = node.ih ? this.textMarkersGap : 0 91 | node.cw = Math.max( 92 | Math.max(node.tw, node.iw) + node.mw + this.padding * 2 + tmGap, 93 | this.defaultWidth 94 | ) 95 | node.ch = Math.max( 96 | this.padding * 2 + node.ih + tiGap + node.th, 97 | this.defaultHeight 98 | ) 99 | const { children } = node 100 | if (!children) { 101 | node.w = node.cw 102 | node.h = node.ch 103 | } else { 104 | const maxW = Math.max(...children.map(c => c.w)) 105 | const sumH = children.reduce((p, c) => p + c.h, 0) 106 | node.h = sumH + this.gapY * (children.length - 1) 107 | node.w = node.cw + this.gapX + maxW 108 | } 109 | 110 | node.outLineW = node.cw - node.outLineOffset * 2 111 | node.outLineH = node.ch - node.outLineOffset * 2 112 | } 113 | 114 | calculateInnerXY(node) { 115 | const { mw, th, mh, ch } = node 116 | node.mx = this.padding 117 | node.tx = node.mx + mw + (mw ? this.textMarkersGap : 0) 118 | node.ty = ch - this.padding - th - 4 119 | node.my = node.ty + th / 2 - mh / 2 + 4 120 | 121 | node.ix = node.tx 122 | node.iy = this.padding 123 | } 124 | 125 | calculateXY(root) { 126 | let anchor 127 | // 前序遍历 计算X 128 | root.eachBefore(node => { 129 | this.calculateInnerXY(node) 130 | const { depth } = node 131 | if (depth === 0) { 132 | node.x = 140 133 | anchor = node 134 | return 135 | } 136 | const { depth: lastDepth, cw, x } = anchor 137 | if (depth === lastDepth) { 138 | node.x = x 139 | } else if (depth > lastDepth) { 140 | node.x = x + cw + this.gapX 141 | } else { 142 | const bro = this.findPrevBrother(node) 143 | node.x = bro.x 144 | } 145 | anchor = node 146 | }) 147 | // 后序遍历 计算Y 148 | anchor = undefined 149 | root.eachAfter(node => { 150 | const { depth } = node 151 | if (!anchor) { 152 | node.y = 100 153 | anchor = node 154 | return 155 | } 156 | const { depth: lastDepth, ch, y } = anchor 157 | if (depth < lastDepth) { 158 | const firstChild = node.children[0] 159 | node.y = firstChild.y + (y - firstChild.y + ch) / 2 - node.ch / 2 160 | // ![BUG]父节点很高 超过了第一个子节点 161 | if (node.y - node.ch / 2 < firstChild.y - firstChild.ch / 2) { 162 | // !TODO 还需要递归的处理该子节点的所有子代 163 | // !F**K 麻烦啊 164 | node.y = firstChild.y + 1 165 | } 166 | } else { 167 | const bottom = this.findBottom(anchor) 168 | node.y = Math.max(bottom.y + bottom.ch + this.gapY, y + ch + this.gapY) 169 | } 170 | anchor = node 171 | }) 172 | } 173 | 174 | /** 175 | * 在已经计算过的节点中寻找与之最靠近的节点 176 | * @param {*} node 177 | * @returns 178 | */ 179 | findBottom(node) { 180 | let bottom = node 181 | while (bottom?.children) { 182 | bottom = bottom.children[bottom.children.length - 1] 183 | } 184 | return bottom 185 | } 186 | 187 | calculatePath(root) { 188 | const links = root.links() 189 | const paths = links.map(l => this.getPathData(l)) 190 | return paths 191 | } 192 | 193 | getPathData(link) { 194 | const { source, target } = link 195 | const { x: sx, y: sy, cw, ch: sh, id: sid } = source 196 | const { x: tx, y: ty, ch, id: tid } = target 197 | // 生成从一个源点到目标点的光滑的三次贝塞尔曲线 198 | const bezierLine = this.bézierCurveGenerator({ 199 | source: { 200 | x: sx + cw, 201 | y: sy + sh / 2 202 | }, 203 | target: { 204 | x: tx, 205 | y: ty + ch / 2 206 | } 207 | }) 208 | return { 209 | data: bezierLine, 210 | id: `path-${sid}-${tid}` 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/hooks/map/Tree.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { select } from 'd3-selection' 3 | import { escape2Html } from '@/hooks/utils' 4 | /** 5 | * 所有导图计算class的父类 6 | * 提取了公共计算方法 这些方法的逻辑不会随导图的风格变化所改变 7 | */ 8 | export default class Tree { 9 | constructor() { 10 | this.elMeasureSvg = select(document.getElementById('measureSvg')) 11 | } 12 | 13 | /** 14 | * 计算单张图片的尺寸 15 | * @param {} node 16 | */ 17 | measureImageSize(node) { 18 | const { imgInfo } = node.data 19 | if (imgInfo) { 20 | node.iw = imgInfo.width 21 | node.ih = imgInfo.height 22 | } else { 23 | node.iw = 0 24 | node.ih = 0 25 | } 26 | } 27 | 28 | /** 29 | * 计算节点文字的尺寸 处理多行情况 30 | * @param {*} node 31 | * @returns 32 | */ 33 | measureTextSize(node) { 34 | const { 35 | depth, 36 | data: { html } 37 | } = node 38 | const fontSize = depth === 0 ? 16 : 14 39 | const lineHeight = fontSize + 2 40 | const t = this.elMeasureSvg.append('text') 41 | t.selectAll('tspan') 42 | .data([html]) 43 | .enter() 44 | .append('tspan') 45 | .text(d => d) 46 | .attr('x', 0) 47 | .attr('style', `font-size:${fontSize}px;line-height:${lineHeight}px;`) 48 | const { width, height } = t.node().getBBox() 49 | t.remove() 50 | 51 | if (width < this.maxWidth) { 52 | // 可以不用分为多行 53 | node.multiline = [html] 54 | node.tw = width 55 | node.th = height 56 | node.tspanDy = height 57 | return 58 | } 59 | 60 | // 文字太长 需要分为多行 61 | // 计算行数 62 | const lines = 63 | Math.floor(width / this.maxWidth) + (width % this.maxWidth ? 1 : 0) 64 | const multiline = [] 65 | // 计算一行消耗多少字符 66 | const lineLength = Math.floor((html.length * this.maxWidth) / width) 67 | for (let i = 0; i < html.length; i += lineLength) { 68 | // 切分lines行 每行lineLength长 69 | multiline.push(escape2Html(html.substr(i, lineLength))) 70 | } 71 | node.multiline = multiline 72 | node.tw = this.maxWidth 73 | node.th = height * lines 74 | node.tspanDy = height 75 | } 76 | 77 | /** 78 | * 计算贴纸标签的尺寸 79 | * @param {*} node 80 | * @returns 81 | */ 82 | measureMarkers(node) { 83 | const { 84 | data: { markerList } 85 | } = node 86 | if (!markerList?.length) { 87 | node.mw = 0 88 | node.mh = 0 89 | return 90 | } 91 | node.mh = this.defaultMarkerHeight 92 | const size = markerList.length 93 | node.mw = this.defaultMarkerWidth * size - this.markerOverlap * (size - 1) 94 | } 95 | 96 | /** 97 | * 找到前一个兄弟节点 98 | * @param {*} node 99 | * @returns 100 | */ 101 | findPrevBrother(node) { 102 | const brothers = node.parent.children 103 | let bro 104 | brothers.every((item, index) => { 105 | if (node.data.id === item.data.id) { 106 | bro = brothers[index - 1] 107 | return false 108 | } 109 | return true 110 | }) 111 | return bro 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/hooks/map/TreeTable.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import Tree from './Tree' 3 | 4 | export default class TableTree extends Tree { 5 | constructor() { 6 | super() 7 | 8 | this.defaultWidth = 150 9 | this.maxWidth = 350 10 | this.defaultHeight = 60 11 | this.defaultRootHeight = 80 12 | this.padding = 20 13 | this.defaultMarkerHeight = 18 14 | this.defaultMarkerWidth = 18 15 | this.markerOverlap = 7 16 | this.textMarkersGap = 10 17 | this.rectRadius = 0 18 | this.strokeWidth = 1.5 19 | } 20 | 21 | create(root) { 22 | this.measureWidthAndHeight(root) 23 | this.calculateXY(root) 24 | return { 25 | paths: undefined, 26 | nodes: root.descendants() 27 | } 28 | } 29 | 30 | measureWidthAndHeight(root) { 31 | // 后序遍历 初步计算 父依赖于子 32 | root.eachAfter(node => { 33 | this.measureTextSize(node) 34 | this.measureMarkers(node) 35 | this.measureImageSize(node) 36 | this.measureWH(node) 37 | }) 38 | // 前序遍历 修正计算 用于宽度充满 子依赖于父 39 | root.eachBefore(node => { 40 | const { children, parent } = node 41 | if (node.depth === 0) return 42 | if (node.depth === 1) { 43 | node.w = parent.w 44 | if (!children) { 45 | // 宽度充满 46 | node.cw = node.w 47 | } 48 | } else { 49 | node.w = parent.w - parent.cw 50 | if (!children) { 51 | node.cw = node.w 52 | } 53 | } 54 | node.outLineW = node.cw - 2 * node.outLineOffset 55 | node.outLineH = node.ch - 2 * node.outLineOffset 56 | }) 57 | } 58 | 59 | measureWH(node) { 60 | node.rectRadius = this.rectRadius 61 | node.strokeWidth = this.strokeWidth 62 | 63 | node.outLineOffset = 2 64 | 65 | const tmGap = node.mw ? this.textMarkersGap : 0 66 | const tiGap = node.ih ? this.textMarkersGap : 0 67 | node.cw = Math.max( 68 | Math.max(node.tw, node.iw) + node.mw + this.padding * 2 + tmGap, 69 | this.defaultWidth 70 | ) 71 | node.ch = Math.max( 72 | this.padding * 2 + node.ih + tiGap + node.th, 73 | this.defaultHeight 74 | ) 75 | 76 | const { children, depth } = node 77 | if (!children) { 78 | node.w = node.cw 79 | node.h = node.ch 80 | } else { 81 | const maxW = Math.max(...children.map(c => c.w)) 82 | const sumH = children.reduce((p, c) => p + c.h, 0) 83 | if (depth === 0) { 84 | node.cw = Math.max(maxW, node.cw) 85 | node.w = node.cw 86 | node.h = node.ch + sumH 87 | } else { 88 | // TODO 假如父元素自身高度超过子元素之和 要扩充最后一个子元素的宽度 89 | // TODO 但是子元素还有子元素呢??? 90 | node.ch = Math.max(node.ch, sumH) 91 | node.w = node.cw + maxW 92 | node.h = node.ch 93 | } 94 | } 95 | } 96 | 97 | measureSelf(node) { 98 | node.rectRadius = this.rectRadius 99 | node.cw = Math.max( 100 | node.tw + node.mw + this.textMarkersGap + this.padding * 2, 101 | this.defaultWidth 102 | ) 103 | node.ch = Math.max(node.th + this.padding * 2, this.defaultHeight) 104 | node.w = node.cw 105 | node.h = node.ch 106 | } 107 | 108 | measureWithChildren(node) { 109 | const { children, depth } = node 110 | const maxW = Math.max(...children.map(c => c.w)) 111 | const sumH = this.sumH(children) 112 | if (depth === 0) { 113 | node.cw = maxW 114 | node.ch = Math.max(node.th + this.padding * 2, this.defaultRootHeight) 115 | node.w = node.cw 116 | node.h = node.ch + sumH 117 | } else { 118 | node.cw = Math.max( 119 | node.tw + node.mw + this.textMarkersGap + this.padding * 2, 120 | this.defaultWidth 121 | ) 122 | // TODO 假如父元素自身高度超过子元素之和 要扩充最后一个子元素的宽度 123 | // TODO 但是子元素还有子元素呢??? 124 | node.ch = Math.max(node.th + this.padding * 2, sumH) 125 | node.w = node.cw + maxW 126 | node.h = node.ch 127 | } 128 | } 129 | 130 | /** 131 | * 计算节点内部元素的位置 132 | * @param {*} node 133 | */ 134 | calculateInnerXY(node) { 135 | const { mw, cw, tw, th, mh, ch, iw, children } = node 136 | if (children) { 137 | node.mx = this.padding 138 | node.tx = node.mx + mw + (mw ? this.textMarkersGap : 0) 139 | node.ty = ch - this.padding - th - 4 140 | node.my = node.ty + th / 2 - mh / 2 + 4 141 | node.ix = node.tx 142 | node.iy = this.padding 143 | } else { 144 | // 文字 145 | node.ty = ch - this.padding - th - 4 146 | node.tx = cw - this.padding - tw 147 | // 标记 148 | node.mx = node.tx - (mw ? this.textMarkersGap : 0) - mw 149 | node.my = node.ty + th / 2 - mh / 2 + 4 150 | // 图片 151 | node.ix = cw - this.padding - iw 152 | node.iy = this.padding 153 | } 154 | } 155 | 156 | /** 157 | * 计算节点的位置 158 | * @param {*} root 159 | */ 160 | calculateXY(root) { 161 | let anchor 162 | root.eachBefore(node => { 163 | this.calculateInnerXY(node) 164 | const { depth } = node 165 | if (depth === 0) { 166 | node.x = 140 167 | node.y = 100 168 | } else if (depth === 1) { 169 | // 第一层的节点需要特殊处理 170 | if (depth < anchor.depth) { 171 | // 上一个被计算的节点不是当前节点的真正兄弟 需要重新找 172 | const realAnchor = this.findPrevBrother(node) 173 | node.x = realAnchor.x 174 | node.y = realAnchor.y + realAnchor.h 175 | } else { 176 | // 上一个被计算的节点是当前节点的上一个兄弟 177 | node.x = anchor.x 178 | node.y = anchor.y + anchor.ch 179 | } 180 | } else if (depth < anchor.depth) { 181 | // 上一个被计算的节点不是当前节点的真正兄弟 需要重新找 182 | const realAnchor = this.findPrevBrother(node) 183 | node.x = realAnchor.x 184 | node.y = realAnchor.y + realAnchor.h 185 | } else if (depth === anchor.depth) { 186 | // 上一个被计算的节点是当前节点的上一个兄弟 187 | node.x = anchor.x 188 | node.y = anchor.y + anchor.h 189 | } else { 190 | // 上一个被计算的节点是当前节点的父节点 当前节点是第一个孩子 191 | node.x = anchor.x + anchor.cw 192 | node.y = anchor.y 193 | } 194 | anchor = node 195 | }) 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/hooks/map/useAutoZoom.js: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted, onActivated, watch, nextTick } from 'vue' 2 | import zoomMap from './zoomMap' 3 | import { debounce } from '../utils' 4 | 5 | export default function useAutoZoom(deps) { 6 | const observedEles = [] 7 | const observers = [] 8 | 9 | const updateCb = debounce(() => { 10 | zoomMap() 11 | }, 500) 12 | 13 | onMounted(() => { 14 | // 监听侧边栏折叠 15 | const $sideContent = document.getElementById('siderContent') 16 | $sideContent && observedEles.push($sideContent) 17 | 18 | observedEles.forEach(ele => { 19 | const watcher = new MutationObserver(updateCb) 20 | watcher.observe(ele, { 21 | attributes: true, 22 | attributeFilter: ['style'] 23 | }) 24 | observers.push(watcher) 25 | }) 26 | // 监听窗口大小变化 27 | window.onresize = updateCb 28 | }) 29 | // 监听导图数据变化 30 | watch( 31 | deps, 32 | () => { 33 | nextTick(updateCb) 34 | }, 35 | { 36 | immediate: true 37 | } 38 | ) 39 | onUnmounted(() => { 40 | window.onresize = null 41 | observers.forEach(watcher => { 42 | watcher.disconnect() 43 | watcher.takeRecords() 44 | }) 45 | observers.length = 0 46 | }) 47 | 48 | onActivated(() => { 49 | updateCb() 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /src/hooks/map/useMap-beta.js: -------------------------------------------------------------------------------- 1 | import { hierarchy } from 'd3-hierarchy' 2 | import TreeTable from './TreeTable' 3 | import LogicTree from './LogicTree-beta.js' 4 | 5 | const useMap = (content, name, nodeMap, mapStyleId) => { 6 | const timerTag = `[TIMER] > ${name} > ` 7 | console.time(timerTag) 8 | const hierarchyData = hierarchy(content) 9 | // TODO content 变动的patchFlags的还原 10 | let renderData 11 | if (mapStyleId === 'MAPID-TreeTable') { 12 | const treeTable = new TreeTable() 13 | renderData = treeTable.create(hierarchyData) 14 | } 15 | const logicTree = new LogicTree(nodeMap) 16 | console.timeEnd(timerTag) 17 | renderData = logicTree.create(hierarchyData) 18 | console.timeEnd(timerTag) 19 | return renderData 20 | } 21 | 22 | export default useMap 23 | -------------------------------------------------------------------------------- /src/hooks/map/useMap.js: -------------------------------------------------------------------------------- 1 | import { hierarchy } from 'd3-hierarchy' 2 | import TreeTable from './TreeTable' 3 | import LogicTree from './LogicTree' 4 | 5 | const useMap = (content, mapStyleId) => { 6 | const hierarchyData = hierarchy(content) 7 | // TODO 当导图风格变多的时候 怎么优雅地处理? 8 | // 考虑proxy策略模式? 9 | let renderData 10 | if (mapStyleId === 'MAPID-TreeTable') { 11 | const treeTable = new TreeTable() 12 | renderData = treeTable.create(hierarchyData) 13 | return renderData 14 | } 15 | const logicTree = new LogicTree() 16 | renderData = logicTree.create(hierarchyData) 17 | return renderData 18 | } 19 | 20 | export default useMap 21 | -------------------------------------------------------------------------------- /src/hooks/map/useMap1.js: -------------------------------------------------------------------------------- 1 | import { hierarchy } from 'd3-hierarchy' 2 | import TreeTable from './TreeTable' 3 | import LogicTree from './LogicTree1.js' 4 | 5 | const useMap = (content, name, nodeMap, mapStyleId) => { 6 | const timerTag = `[TIMER] > ${name} > ` 7 | console.time(timerTag) 8 | const hierarchyData = hierarchy(content) 9 | // TODO content 变动的patchFlags的还原 10 | let renderData 11 | if (mapStyleId === 'MAPID-TreeTable') { 12 | const treeTable = new TreeTable() 13 | renderData = treeTable.create(hierarchyData) 14 | } 15 | const logicTree = new LogicTree(nodeMap) 16 | console.timeEnd(timerTag) 17 | renderData = logicTree.create(hierarchyData) 18 | console.timeEnd(timerTag) 19 | return renderData 20 | } 21 | 22 | export default useMap 23 | -------------------------------------------------------------------------------- /src/hooks/map/zoomMap.js: -------------------------------------------------------------------------------- 1 | import { zoom, zoomIdentity } from 'd3-zoom' 2 | import { select } from 'd3-selection' 3 | 4 | /** 5 | * 使图具有缩放能力 6 | */ 7 | function registerZoom(svgSele, gSele) { 8 | const zoomer = zoom() 9 | .on('zoom', event => { 10 | gSele.attr('transform', event.transform) 11 | }) 12 | .scaleExtent([0.1, 4]) 13 | // .translateExtent([[-1000, -1000], [1000, 800]]) 14 | zoomer(svgSele) 15 | svgSele.on('dblclick.zoom', null) 16 | return zoomer 17 | } 18 | /** 19 | * 使导图适应当前屏幕大小 20 | */ 21 | const zoomMap = () => { 22 | const elMainSvg = document.getElementById('mainSvg') 23 | const elMainG = document.getElementById('mainG') 24 | if (!elMainSvg || !elMainG) return 25 | 26 | const mainGSelection = select(elMainG) 27 | const mainSvgSelection = select(elMainSvg) 28 | 29 | const gMetrics = elMainG.getBBox() 30 | const svgMetrics = elMainSvg.getBoundingClientRect() 31 | 32 | // 计算缩放尺度 33 | // [+20]的目的是留出一部分边界空隙 34 | const scale = Math.min( 35 | svgMetrics.width / (gMetrics.width + 140), 36 | svgMetrics.height / (gMetrics.height + 100) 37 | ) 38 | 39 | // 计算移动的中心 40 | const svgCenter = { x: svgMetrics.width / 2, y: svgMetrics.height / 2 } 41 | const gCenter = { 42 | x: (gMetrics.width * scale) / 2, 43 | y: (gMetrics.height * scale) / 2 44 | } 45 | const center = zoomIdentity 46 | .translate( 47 | -gMetrics.x * scale + svgCenter.x - gCenter.x, 48 | -gMetrics.y * scale + svgCenter.y - gCenter.y 49 | ) 50 | .scale(scale) 51 | const zoomer = registerZoom(mainSvgSelection, mainGSelection) 52 | if (!zoomer) return 53 | mainSvgSelection.transition().duration(500).call(zoomer.transform, center) 54 | } 55 | 56 | export default zoomMap 57 | -------------------------------------------------------------------------------- /src/hooks/note/useNote.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { watchEffect, ref } from 'vue' 3 | import useMapStore from '@/store/map' 4 | 5 | export default function useNoteList() { 6 | const store = useMapStore() 7 | const rootNode = ref(null) 8 | const childNodes = ref([]) 9 | watchEffect(() => { 10 | if (!store.treeContent) return 11 | // 监听treeContent的变化 改变大纲编辑页需要展示的数据 12 | // TODO 【可优化】:需要每次更新都重新全量地获取先序序列么? 13 | const preOrder = cyclePreOrder(store.treeContent) 14 | // eslint-disable-next-line prefer-destructuring 15 | rootNode.value = preOrder[0] 16 | childNodes.value = preOrder.slice(1) 17 | }) 18 | return [rootNode, childNodes] 19 | } 20 | 21 | /** 22 | * 获取多叉树的先序序列 = 大纲编辑页的展示顺序 23 | * @param {*} root 24 | * @returns 25 | */ 26 | function cyclePreOrder(root) { 27 | const stack = [] 28 | const res = [] 29 | root.level = 0 30 | stack.push(root) 31 | while (stack.length) { 32 | const cur = stack.pop() 33 | res.push(cur) 34 | const len = cur?.children?.length 35 | if (len) { 36 | cur.children.forEach((v, i) => { 37 | // ! 倒序入栈才能顺序出栈 38 | const child = cur.children[len - 1 - i] 39 | child.level = cur.level + 1 40 | stack.push(child) 41 | }) 42 | } 43 | } 44 | return res 45 | } 46 | -------------------------------------------------------------------------------- /src/hooks/svg2Png.js: -------------------------------------------------------------------------------- 1 | import saveSvg from 'save-svg-as-png' 2 | 3 | export default function svg2Png(svgId, picName) { 4 | return new Promise((resolve, reject) => { 5 | const $svg = document.getElementById(svgId) 6 | if (!$svg) reject(new Error('SVG未找到')) 7 | 8 | const $mainG = $svg.firstChild 9 | if (!$mainG) reject(new Error('SVG无子元素')) 10 | 11 | // https://developer.mozilla.org/zh-CN/docs/Web/API/SVGGraphicsElement/getBBox 12 | // getBBox相对于svg空间 返回不受transform影响的原始宽高 13 | const gMetrics = $mainG.getBBox() 14 | 15 | const svgMetrics = { 16 | width: gMetrics.width + 20, 17 | height: gMetrics.height + 20 18 | } 19 | // 不让[saveSvg]中的图片下载操作影响到源svg 需要拷贝副本 20 | const $clonedSvg = $svg.cloneNode(true) 21 | const bgColor = $svg.style.backgroundColor 22 | // 为svg拷贝设置新的尺寸 -> 基于标签的原始大小 23 | $clonedSvg.setAttribute( 24 | 'style', 25 | `width:${svgMetrics.width}px;height:${svgMetrics.height}px;background-color:${bgColor}` 26 | ) 27 | 28 | // 计算标签的缩放尺度 29 | // [+20]的目的是留出一部分边界空隙 30 | const scale = Math.min( 31 | svgMetrics.width / (gMetrics.width + 20), 32 | svgMetrics.height / (gMetrics.height + 10) 33 | ) 34 | 35 | // 计算标签移动的距离 36 | const svgCenter = { x: svgMetrics.width / 2, y: svgMetrics.height / 2 } 37 | const gCenter = { 38 | x: (gMetrics.width * scale) / 2, 39 | y: (gMetrics.height * scale) / 2 40 | } 41 | 42 | // 标签移动到svg拷贝的中心并适配大小 43 | const transX = -gMetrics.x * scale + svgCenter.x - gCenter.x 44 | const transY = -gMetrics.y * scale + svgCenter.y - gCenter.y 45 | $clonedSvg.firstChild.setAttribute( 46 | 'transform', 47 | `translate(${transX}, ${transY}) scale(${scale})` 48 | ) 49 | 50 | // 添加水印 51 | const $waterPrint = document.createElementNS( 52 | 'http://www.w3.org/2000/svg', 53 | 'text' 54 | ) 55 | $waterPrint.setAttribute('x', svgMetrics.width - 150) 56 | $waterPrint.setAttribute('y', svgMetrics.height - 10) 57 | // TODO 可以优化 水印的颜色设置为当前背景的反转就不用担心水印看不到了 58 | $waterPrint.setAttribute('style', 'color:#000;opacity:0.2;font-size:16px;') 59 | $waterPrint.innerHTML = '@map.kimjisoo.cn' 60 | $clonedSvg.appendChild($waterPrint) 61 | // 准备工作完成 开始转换 62 | resolve($clonedSvg) 63 | }).then(ele => { 64 | // saveSvg.saveSvgAsPng 返回转化处理的Promise 65 | return saveSvg.saveSvgAsPng(ele, `${picName}.png`, { 66 | excludeCss: true 67 | }) 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /src/hooks/useContent.js: -------------------------------------------------------------------------------- 1 | import useMapStore from '@/store/map' 2 | import { xss, getImageWH } from '@/hooks/utils' 3 | import { customAlphabet } from 'nanoid' 4 | 5 | const nanoid = customAlphabet('1234567890abcdef', 5) 6 | const store = useMapStore() 7 | 8 | export async function addNode(pid, options = { isMap: false, cid: undefined }) { 9 | const { content } = store 10 | const node = content[pid] 11 | if (!node) return null 12 | // 如果当前节点折叠 先打开 13 | if (node._children.length) { 14 | ;[node.children, node._children] = [node._children, node.children] 15 | } 16 | const id = nanoid() 17 | const { isMap, cid } = options 18 | if (!cid) { 19 | // 添加孩子 => 添加到第一个 20 | if (isMap) { 21 | node.children.push(id) 22 | } else { 23 | node.children.splice(0, 0, id) 24 | } 25 | } else { 26 | // 添加兄弟 => 添加到当前位置的下一个 27 | const index = node.children.indexOf(options.cid) 28 | node.children.splice(index + 1, 0, id) 29 | } 30 | const child = { 31 | html: '请输入内容', 32 | id, 33 | children: [], 34 | _children: [], 35 | parent: pid, 36 | markerList: [], 37 | collapsed: false 38 | } 39 | content[id] = child 40 | await store.setContent(content) 41 | return id 42 | } 43 | 44 | export async function deleteNode(id, list = undefined) { 45 | // 更新其父节点的信息 46 | const { content } = store 47 | console.log(content[id], id) 48 | const p = content[content[id].parent] 49 | // 没有父节点 => 是根节点 => 根节点不能被删除 50 | if (!p) return null 51 | p.children = p.children.filter(v => v !== id) 52 | // 删除此节点和其所有后代 53 | const queue = [id] 54 | while (queue.length) { 55 | const front = queue.shift() 56 | queue.push(...content[front].children) 57 | delete content[front] 58 | } 59 | await store.setContent(content) 60 | // list==undefined => 是在map中删除 不需要定位焦点 61 | if (!list) return null 62 | // 返回上一个ID 63 | let prevId 64 | // 删除的是第一个节点 焦点将给到第二个节点 65 | if (list[0].id === id) { 66 | prevId = list[1].id 67 | } else { 68 | // eslint-disable-next-line no-restricted-syntax 69 | for (const index in list) { 70 | if (list[index].id === id) { 71 | prevId = list[index - 1].id 72 | break 73 | } 74 | } 75 | } 76 | return prevId 77 | } 78 | export async function collapse(id) { 79 | const { content } = store 80 | const node = content[id] 81 | ;[node.children, node._children] = [node._children, node.children] 82 | await store.setContent(content) 83 | } 84 | 85 | export function moveToLastFocus(id) { 86 | // 把光标移到文字最后面 87 | const $el = document.getElementById(id) 88 | const range = document.createRange() 89 | range.selectNodeContents($el) 90 | range.collapse(false) 91 | const sel = window.getSelection() 92 | sel.removeAllRanges() 93 | sel.addRange(range) 94 | } 95 | 96 | export async function tabNode(id, noteList) { 97 | // 1. 首先判断能不能tab 98 | // 该节点作为第一个孩子节点时 不能tab 99 | const { content } = store 100 | const node = content[id] 101 | const pNode = content[node.parent] 102 | const index = pNode.children.indexOf(id) 103 | const canTab = index !== 0 104 | if (!canTab) return null 105 | // 2. 找到要tab到哪个节点下 106 | let newPid 107 | // eslint-disable-next-line no-restricted-syntax 108 | for (const i in noteList) { 109 | if (id === noteList[i].id) { 110 | newPid = noteList[i - 1].id 111 | break 112 | } 113 | } 114 | // 3. 旧的父node删除该节点 115 | pNode.children.splice(index, 1) 116 | // 4. 该节点重新赋值新的父node id 117 | node.parent = newPid 118 | // 5. 如果newPid折叠了,打开 119 | const newPNode = content[newPid] 120 | if (newPNode._children.length) { 121 | ;[newPNode.children, newPNode._children] = [ 122 | newPNode._children, 123 | newPNode.children 124 | ] 125 | } 126 | // 6. 该节点作为newPid的最后一个孩子 127 | newPNode.children.push(id) 128 | await store.setContent(content) 129 | return id 130 | } 131 | 132 | export async function changeNodeHtml(id, html) { 133 | const { content } = store 134 | // ! 由于debounce 此事件可能发生在deleteNode之后 此id节点可能被删除 需要判空 135 | if (!content[id]) return 136 | content[id].html = xss(html) 137 | await store.setContent(content, true) 138 | } 139 | 140 | export async function changeNodeMarkers(id, markerList) { 141 | const { content } = store 142 | // ! 由于debounce 此事件可能发生在deleteNode之后 此id节点可能被删除 需要判空 143 | if (!content[id]) return 144 | content[id].markerList = markerList 145 | await store.setContent(content) 146 | } 147 | 148 | export async function pasteImg(file, nodeId) { 149 | const { width, height } = await getImageWH(file) 150 | const { content } = store 151 | // TODO 把这些默认值写在一个统一的配置文件里 152 | // 在上传过程种设置一个loading占位 153 | content[nodeId].imgInfo = { 154 | url: 'https://cdn.kimjisoo.cn/pic%2Floading.svg', 155 | width: 250, 156 | height: (250 * height) / width 157 | } 158 | store.setContent(content, true) 159 | await store.pasteImg({ file, nodeId, width, height }) 160 | } 161 | 162 | export async function deleteImg(nodeId) { 163 | const { content } = store 164 | content[nodeId].imgInfo = undefined 165 | await store.setContent(content) 166 | } 167 | -------------------------------------------------------------------------------- /src/hooks/useIntroStyle.js: -------------------------------------------------------------------------------- 1 | import { ref, computed } from 'vue' 2 | 3 | const threshold = 590 4 | // const thresholdX = 947 5 | // --slogan-svg-offset-y: 0px; 6 | // --slogan-svg-scale-rate: 0.6435; 7 | // --hero-slogan-color-2: rgb(255 55 55); 8 | // --hero-slogan-color-1: rgb(108 105 255); 9 | export default function useIntroStyle() { 10 | const sloganColorA = ref('rgba(0,0,0,1)') 11 | const sloganColorB = ref('rgba(0,0,0,1)') 12 | const sloganOffsetY = ref(104) 13 | const sloganScaleRate = ref(1) 14 | const sloganStyle = computed(() => { 15 | return { 16 | '--hero-slogan-color-1': sloganColorA.value, 17 | '--hero-slogan-color-2': sloganColorB.value, 18 | transform: `translate3d(0,${sloganOffsetY.value}px,0) scale(${sloganScaleRate.value})` 19 | } 20 | }) 21 | const updateStyle = scrollTop => { 22 | let rate = scrollTop / threshold 23 | if (rate > 1) rate = 1 24 | sloganColorA.value = `rgba(${rate * 255},${rate * 55},${rate * 55},1)` 25 | sloganColorB.value = `rgba(${rate * 108},${rate * 105},${rate * 255},1)` 26 | sloganOffsetY.value = (1 - rate) * 104 27 | sloganScaleRate.value = 1 - 0.4 * rate 28 | } 29 | return [sloganStyle, updateStyle] 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/useLogin.js: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/hooks/useMapStyle.js: -------------------------------------------------------------------------------- 1 | import useWebsiteStore from '@/store/website' 2 | 3 | const websiteStore = useWebsiteStore() 4 | 5 | export default function useMapStyle(styles) { 6 | const { colorId } = styles 7 | const allColors = websiteStore.styles.colorList 8 | const { 9 | style: { colors } 10 | } = allColors.find(item => item.id === colorId) 11 | return { 12 | svgStyle: { 13 | width: '100%', 14 | height: '100%', 15 | backgroundColor: colors.bgSvg 16 | }, 17 | pathStyle: { 18 | stroke: colors.path, 19 | fill: 'none', 20 | strokeWidth: '1.5px' 21 | }, 22 | imageStyle: { 23 | display: 'none' 24 | }, 25 | rectStyle: node => { 26 | let style = { 27 | stroke: colors.border, 28 | strokeWidth: `${node.strokeWidth}px` 29 | } 30 | switch (node.depth) { 31 | case 0: 32 | style = { ...style, fill: colors.bgRoot } 33 | break 34 | case 1: 35 | style = { ...style, fill: colors.bgSubRoot } 36 | break 37 | default: 38 | style = { ...style, fill: colors.bgLeaf } 39 | } 40 | return style 41 | }, 42 | textStyle: node => { 43 | const _fontSize = node.depth === 0 ? 16 : 14 44 | const _lineHeight = _fontSize + 2 45 | let style = { 46 | fontSize: `${_fontSize}px`, 47 | lineHeight: `${_lineHeight}px`, 48 | textAnchor: 'start', 49 | whiteSpace: 'initial' 50 | } 51 | switch (node.depth) { 52 | case 0: 53 | style = { ...style, color: colors.textRoot } 54 | break 55 | case 1: 56 | style = { ...style, color: colors.textSubRoot } 57 | break 58 | default: 59 | style = { ...style, color: colors.textLeaf } 60 | } 61 | return style 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/hooks/useSnapshot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 配合[ctrl + z]实现操作可撤回 3 | */ 4 | import { onMounted, onUnmounted } from 'vue' 5 | import useMapStore from '@/store/map' 6 | import { deepClone } from './utils' 7 | 8 | class Snapshot { 9 | constructor(length = 20) { 10 | this.length = length 11 | this.snapshots = [] 12 | this.cursor = -1 13 | } 14 | 15 | get hasPrev() { 16 | return this.cursor > 0 17 | } 18 | 19 | snap(data) { 20 | // 记录数据快照 21 | const snapshot = deepClone(data) 22 | // 去除旧分支 23 | while (this.cursor < this.snapshots.length - 1) { 24 | this.snapshots.pop() 25 | } 26 | this.snapshots.push(snapshot) 27 | // 确保历史记录条数限制 28 | if (this.snapshots.length > this.length) { 29 | this.snapshots.shift() 30 | } 31 | this.cursor = this.snapshots.length - 1 32 | } 33 | 34 | prev() { 35 | if (this.hasPrev) { 36 | this.cursor -= 1 37 | return deepClone(this.snapshots[this.cursor]) 38 | } 39 | return null 40 | } 41 | } 42 | 43 | export default function useSnapShot() { 44 | const snapshot = new Snapshot() 45 | const store = useMapStore() 46 | onMounted(() => { 47 | document.onkeydown = ev => { 48 | if (ev.ctrlKey && ev.keyCode === 90) { 49 | ev.preventDefault() 50 | if (snapshot.hasPrev) { 51 | store.setContent(snapshot.prev().content) 52 | } 53 | } 54 | } 55 | }) 56 | onUnmounted(() => { 57 | document.onkeydown = undefined 58 | }) 59 | const addSnapShot = () => { 60 | snapshot.snap({ content: store.content }) 61 | } 62 | return addSnapShot 63 | } 64 | -------------------------------------------------------------------------------- /src/hooks/useWorker.js: -------------------------------------------------------------------------------- 1 | import Worker from './map.worker' 2 | 3 | const myWorker = new Worker() 4 | const callbackMap = new WeakMap() 5 | 6 | myWorker.onmessage = e => { 7 | const { result, type } = e.data 8 | const callback = callbackMap?.get(type) 9 | if (callback) { 10 | callback(result) 11 | } 12 | } 13 | /** 14 | * TODO 回调函数 不够优雅 怎么解决 15 | * @param {*} source 16 | * @param {*} callback 17 | */ 18 | export default function getMd5(source, callback) { 19 | callbackMap.set('MD5', callback) 20 | myWorker.postMessage({ source, type: 'MD5' }) 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/utils.js: -------------------------------------------------------------------------------- 1 | import { ElMessage } from 'element-plus' 2 | import filterXSS from 'xss' 3 | 4 | /** 5 | * 深拷贝:新对象的改变不影响旧对象 6 | * 1. [JSON.stringfy JSON.parse]: 无法克隆方法/循环引用无法解决 7 | * 2. [递归]: 循环引用无法解决 改进: 使用Map记录是否克隆过 8 | */ 9 | export const deepClone = (target, map = new Map()) => { 10 | if (target === null || typeof target !== 'object') { 11 | return target 12 | } 13 | const cache = map.get(target) 14 | if (cache) { 15 | return cache 16 | } 17 | const isArray = Array.isArray(target) 18 | const result = isArray ? [] : {} 19 | map.set(target, result) 20 | if (isArray) { 21 | target.forEach((item, index) => { 22 | result[index] = deepClone(item, map) 23 | }) 24 | } else { 25 | Object.keys(target).forEach(key => { 26 | result[key] = deepClone(target[key], map) 27 | }) 28 | } 29 | return result 30 | } 31 | 32 | export const ErrorTip = msg => { 33 | ElMessage.error(msg) 34 | } 35 | 36 | export const dateFormatter = timestamp => { 37 | if (!timestamp) return '' 38 | const date = new Date(timestamp) 39 | let year = date.getFullYear() 40 | let month = date.getMonth() + 1 41 | let day = date.getDate() 42 | const hour = date.getHours() 43 | let minutes = date.getMinutes() 44 | if (year === new Date().getFullYear()) { 45 | year = '' 46 | } 47 | if (month < 10) { 48 | month = `0${month}` 49 | } 50 | if (day < 10) { 51 | day = `0${day}` 52 | } 53 | if (minutes < 10) { 54 | minutes = `0${minutes}` 55 | } 56 | return `${year ? `${year}年` : ''}${month}月${day}日 ${hour}:${minutes}` 57 | } 58 | 59 | // 防抖:动作绑定事件,动作发生后一定时间后触发事件,在这段时间内,如果该动作又发生,则重新等待一定时间再触发事件。 60 | export function debounce(fn, wait) { 61 | let timer 62 | return function (...args) { 63 | const _this = this 64 | if (timer) { 65 | clearTimeout(timer) 66 | } 67 | timer = setTimeout(function () { 68 | fn.apply(_this, args) 69 | }, wait) 70 | } 71 | } 72 | 73 | // 节流: 动作绑定事件,动作发生后一段时间后触发事件,在这段时间内,如果动作又发生,则无视该动作,直到事件执行完后,才能重新触发。 74 | export function throttle(fn, wait) { 75 | let timer 76 | return function (...args) { 77 | const _this = this 78 | if (!timer) { 79 | timer = setTimeout(function () { 80 | timer = null 81 | fn.apply(_this, args) 82 | }, wait) 83 | } 84 | } 85 | } 86 | 87 | // TODO 需要抽取到一个统一的website配置json里 通过http接口返回 不直接嵌入代码 88 | const xssFilterOptions = { 89 | stripIgnoreTagBody: true, // 不在白名单中的标签以及标签里面的内容直接删除 90 | whiteList: { 91 | h1: ['style'], 92 | h2: ['style'], 93 | h3: ['style'], 94 | h4: ['style'], 95 | h5: ['style'], 96 | h6: ['style'], 97 | hr: ['style'], 98 | span: ['style'], 99 | strong: ['style'], 100 | b: ['style'], 101 | i: ['style'], 102 | br: [], 103 | p: ['style'], 104 | pre: ['style'], 105 | code: ['style'], 106 | a: ['style', 'target', 'href', 'title', 'rel'], 107 | img: ['style', 'src', 'title'], 108 | div: ['style'], 109 | table: ['style', 'width', 'border'], 110 | tr: ['style'], 111 | td: ['style', 'width', 'colspan'], 112 | th: ['style', 'width', 'colspan'], 113 | tbody: ['style'], 114 | ul: ['style'], 115 | li: ['style'], 116 | ol: ['style'], 117 | dl: ['style'], 118 | dt: ['style'], 119 | em: ['style'], 120 | cite: ['style'], 121 | section: ['style'], 122 | header: ['style'], 123 | footer: ['style'], 124 | blockquote: ['style'], 125 | audio: ['autoplay', 'controls', 'loop', 'preload', 'src'], 126 | video: ['autoplay', 'controls', 'loop', 'preload', 'src', 'height', 'width'] 127 | }, 128 | css: { 129 | whiteList: { 130 | color: true, 131 | 'background-color': true, 132 | width: true, 133 | height: true, 134 | 'max-width': true, 135 | 'max-height': true, 136 | 'min-width': true, 137 | 'min-height': true, 138 | 'font-size': true 139 | } 140 | } 141 | } 142 | export function xss(content) { 143 | return filterXSS(content, xssFilterOptions) 144 | } 145 | /** 146 | * 获取图片的原始宽高 147 | * TODO 错误处理 reject 148 | * @param {*} url 可能是文件或者string 149 | * @returns 150 | */ 151 | export function getImageWH(url) { 152 | return new Promise(resolve => { 153 | if (url instanceof File) { 154 | const reader = new FileReader() 155 | reader.onload = e => { 156 | // 把获取到的图片展示 157 | resolve(e.target.result) 158 | } 159 | reader.readAsDataURL(url) 160 | } else { 161 | resolve(url) 162 | } 163 | }).then(res => { 164 | return new Promise(resolve => { 165 | const img = new Image() 166 | img.onload = () => { 167 | const width = img.naturalWidth 168 | const height = img.naturalHeight 169 | resolve({ width, height }) 170 | } 171 | img.src = res 172 | }) 173 | }) 174 | } 175 | 176 | export function escape2Html(str) { 177 | const escapeMap = { lt: '<', gt: '>', nbsp: ' ', amp: '&', quot: '"' } 178 | return str.replace(/&(lt|gt|nbsp|amp|quot);/gi, (all, t) => escapeMap[t]) 179 | } 180 | 181 | export function betterInterval(cb, wait) { 182 | let timer 183 | const timeUp = () => { 184 | clearTimeout(timer) 185 | cb && cb() 186 | timer = setTimeout(timeUp, wait) 187 | } 188 | timer = setTimeout(timeUp, wait) 189 | return { 190 | clear: () => clearTimeout(timer) 191 | } 192 | } 193 | 194 | // 自定义验证规则 195 | const validatePass = (rule, value, callback) => { 196 | // 密码只能由大小写英文字母或数字开头,且由大小写英文字母_.组成 197 | const reg = /^[A-Za-z0-9][A-Za-z0-9_.]{5,14}$/ 198 | if (!value.match(reg)) { 199 | callback(new Error('密码由字母或数字开头,且只能为字母,数字,下划线及(.)')) 200 | } else { 201 | callback() 202 | } 203 | } 204 | 205 | export function getLoginRules() { 206 | return { 207 | email: [ 208 | { required: true, message: '邮箱不能为空', trigger: 'blur' }, 209 | { type: 'email', message: '邮箱格式不正确', trigger: 'blur' } 210 | ], 211 | pwd: [ 212 | { required: true, message: '密码不能为空', trigger: 'blur' }, 213 | { min: 6, max: 15, message: '密码位数只能在6~15之间', trigger: 'blur' }, 214 | { validator: validatePass, trigger: 'blur' } 215 | ] 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import * as Sentry from '@sentry/vue' 3 | import { BrowserTracing } from '@sentry/tracing' 4 | import App from './App.vue' 5 | import router from './router' 6 | import store from './store' 7 | import '@/assets/css/reset.css' 8 | 9 | // 一次性引入所有svg图 10 | const req = require.context('./assets/pic', false, /\.svg$/) 11 | const requireAll = requireContext => requireContext.keys().map(requireContext) 12 | requireAll(req) 13 | 14 | const app = createApp(App) 15 | 16 | // TODO 待完善全局异常捕获 17 | // app.config.errorHandler = (err, vm, info) => { 18 | // console.log('[全局异常]', err, vm, info) 19 | // } 20 | 21 | const websiteCfg = JSON.parse(localStorage.getItem('zmindmap_website') || '{}') 22 | const isDark = websiteCfg?.isDark 23 | window.document.documentElement.setAttribute( 24 | 'data-theme', 25 | isDark ? 'dark' : 'light' 26 | ) 27 | const { sentryCfg } = window.CFG 28 | if (sentryCfg.tracingOrigins.includes(window.location.hostname)) { 29 | Sentry.init({ 30 | app, 31 | dsn: sentryCfg.dsn, 32 | integrations: [ 33 | new BrowserTracing({ 34 | routingInstrumentation: Sentry.vueRouterInstrumentation(router), 35 | tracingOrigins: sentryCfg.tracingOrigins 36 | }) 37 | ], 38 | tracesSampleRate: sentryCfg.tracesSampleRate 39 | }) 40 | } 41 | 42 | app.use(store).use(router).mount('#app') 43 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import useUserStore from '@/store/user' 3 | import Home from '@/views/Home' 4 | import Folder from '@/views/Folder' 5 | import NotFound from '@/views/NotFound.vue' 6 | import Intro from '@/views/Intro.vue' 7 | 8 | const routes = [ 9 | { 10 | path: '/', 11 | redirect: '/app' 12 | }, 13 | { 14 | path: '/app', 15 | name: 'Home', 16 | component: Home, 17 | redirect: '/app/folder', 18 | children: [ 19 | { 20 | path: 'edit/:id/:view?', 21 | component: () => 22 | import(/* webpackChunkName: "edit-patch" */ '@/views/Edit.vue') 23 | }, 24 | { 25 | path: 'folder/:id?', 26 | component: Folder 27 | } 28 | ] 29 | }, 30 | { 31 | path: '/login', 32 | name: 'Login', 33 | meta: { 34 | isLogin: true 35 | }, 36 | // route level code-splitting 37 | // this generates a separate chunk (about.[hash].js) for this route 38 | // which is lazy-loaded when the route is visited. 39 | component: () => 40 | import(/* webpackChunkName: "login-patch" */ '../views/Login') 41 | }, 42 | { 43 | path: '/404', 44 | component: NotFound 45 | }, 46 | { 47 | path: '/intro', 48 | component: Intro 49 | }, 50 | { 51 | path: '/:catchAll(.*)', // 不识别的path自动匹配404 52 | redirect: '/404' 53 | } 54 | ] 55 | 56 | const router = createRouter({ 57 | history: createWebHistory(), 58 | routes 59 | }) 60 | router.beforeEach((to, from, next) => { 61 | // 百度统计 62 | if (to.path && window._hmt) { 63 | window._hmt.push(['_trackPageview', to.fullPath]) 64 | } 65 | const store = useUserStore() 66 | if (to.meta.isLogin) { 67 | // 如果去登陆页的话 不用验证token 68 | next() 69 | } else if (store?.token || localStorage.getItem('token')) { 70 | // 验证是否登录了 71 | next() 72 | } else { 73 | next({ path: '/login', query: { redirect: to.path } }) 74 | } 75 | }) 76 | 77 | export default router 78 | -------------------------------------------------------------------------------- /src/store/doc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /** 3 | * 文档相关状态 4 | */ 5 | import { defineStore } from 'pinia' 6 | import { dateFormatter } from '@/hooks/utils' 7 | import { docApi } from '@/hooks/http' 8 | 9 | const useDocStore = defineStore({ 10 | id: 'doc', 11 | state: () => ({ 12 | originAllDocs: undefined, 13 | allTreeDocs: undefined 14 | }), 15 | getters: { 16 | getAllDocuments: state => id => { 17 | if (!state.originAllDocs) return [] 18 | if (!id) return state.allTreeDocs 19 | const { folders, documents } = state.originAllDocs 20 | return [...folders, ...documents].filter(doc => doc.folderId === id) 21 | }, 22 | getNavigationLists: state => curFolderId => { 23 | const paths = [] 24 | const folderList = state.originAllDocs?.folders 25 | if (!folderList || !folderList.length) return [] 26 | const curFolder = folderList.find(f => f.id === curFolderId) 27 | if (curFolder) { 28 | paths.unshift(curFolder) 29 | let prevFolderId = curFolder.folderId 30 | while (prevFolderId !== '0') { 31 | // eslint-disable-next-line no-loop-func 32 | const prevFolder = folderList.find(f => f.id === prevFolderId) 33 | if (!prevFolder) break 34 | paths.unshift(prevFolder) 35 | prevFolderId = prevFolder.folderId 36 | } 37 | } 38 | paths.unshift({ name: '我的文件', id: '0' }) 39 | return paths 40 | } 41 | }, 42 | actions: { 43 | setDoc(data) { 44 | if (!data) return 45 | this.originAllDocs = data 46 | this.allTreeDocs = processTreeData(data) 47 | }, 48 | async fetchAllDocuments() { 49 | const data = await docApi.fetchAllDocuments() 50 | this.setDoc(data) 51 | }, 52 | async postSetFolder(data) { 53 | const res = await docApi.postSetFolder(data) 54 | this.setDoc(res) 55 | }, 56 | async postSetDoc(data) { 57 | const res = await docApi.postSetDoc(data) 58 | this.setDoc(res) 59 | }, 60 | async postRemove(data) { 61 | const res = await docApi.postRemove(data) 62 | this.setDoc(res) 63 | } 64 | }, 65 | persist: { 66 | enabled: true, 67 | strategies: [ 68 | { 69 | key: 'zmindmap_docs', 70 | storage: localStorage 71 | } 72 | ] 73 | } 74 | }) 75 | 76 | function processTreeData(data) { 77 | if (!data) return null 78 | const docs = [...data.folders, ...data.documents] 79 | const treeData = docs.filter(item => { 80 | item.formatedUpdateTime = dateFormatter(item.updateTime) 81 | item.formatedCreateTime = dateFormatter(item.createTime) 82 | item.children = docs.filter(e => { 83 | return item.id === e.folderId 84 | }) 85 | return item.folderId === '0' 86 | }) 87 | return treeData 88 | } 89 | 90 | export default useDocStore 91 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import piniaPluginPersist from 'pinia-plugin-persist' 3 | 4 | const store = createPinia() 5 | store.use(piniaPluginPersist) 6 | 7 | export default store 8 | -------------------------------------------------------------------------------- /src/store/map.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-restricted-syntax */ 3 | /** 4 | * 导图和大纲笔记页面相关状态 5 | */ 6 | import { defineStore } from 'pinia' 7 | import { ErrorTip } from '@/hooks/utils' 8 | import { mapApi } from '@/hooks/http' 9 | 10 | const useMapStore = defineStore('map', { 11 | state: () => ({ 12 | // ? 【还是TypeScript好】 13 | mapData: undefined, // 导图所有原始数据:包括id,内容,风格等 14 | content: undefined, // 导图内容的纯数据扁平结构 15 | treeContent: undefined, // 导图内容的纯数据树形结构 16 | isSaving: false, // 设置Edit页面的数据的加载状态 17 | idFocused: undefined // 当前时刻被选中的节点ID 18 | }), 19 | actions: { 20 | setIdFocused(id) { 21 | this.idFocused = id 22 | }, 23 | // 更新content 同时统一处理isSaving的状态 24 | async updateWithLoading(makeNewData) { 25 | if (!makeNewData || typeof makeNewData !== 'function') return 26 | if (this.isSaving) { 27 | ErrorTip('操作过于频繁!') 28 | return 29 | } 30 | this.isSaving = true 31 | const data = makeNewData() 32 | await this.remoteUpdateMap(data) 33 | this.isSaving = false 34 | }, 35 | // 一般的导图节点级别更新 36 | async setContent(content) { 37 | await this.updateWithLoading(() => { 38 | this.content = content 39 | this.treeContent = flatToTree(content) 40 | return { 41 | ...this.mapData, 42 | definition: JSON.stringify(content) 43 | } 44 | }) 45 | }, 46 | // 整个导图的风格更新 47 | async setStyle(newStyle) { 48 | this.updateWithLoading(() => { 49 | return { 50 | ...this.mapData, 51 | styles: newStyle 52 | } 53 | }) 54 | }, 55 | /** 56 | * 从网络拿到导图信息后 放入store 57 | * @param {*} data 58 | */ 59 | setDataFromRemote(data) { 60 | if (data && data.definition) { 61 | this.mapData = data 62 | this.content = JSON.parse(data.definition) 63 | this.treeContent = flatToTree(this.content) 64 | } else { 65 | ErrorTip('保存失败') 66 | } 67 | }, 68 | /** 69 | * 初始获取导图数据 70 | * @param {*} docId 71 | */ 72 | async fetchMap(docId) { 73 | const res = await mapApi.fetchMap(docId) 74 | this.setDataFromRemote(res) 75 | }, 76 | /** 77 | * 向数据库提交更新 78 | * @param {*} data 79 | * @param {*} onFinish 80 | */ 81 | async remoteUpdateMap(data) { 82 | const res = await mapApi.remoteUpdateMap(data) 83 | this.setDataFromRemote(res) 84 | }, 85 | /** 86 | * 粘贴图片的逻辑 87 | * @param {*} param0 88 | * @returns 89 | */ 90 | async pasteImg({ file, nodeId, width, height }) { 91 | const formData = new FormData() 92 | if (file) { 93 | formData.append('file', file) 94 | } 95 | // 首先上传图片,获得图片的url 96 | const imgUrl = await mapApi.uploadImg({ 97 | data: formData, 98 | headers: { 99 | 'Content-Type': 'multipart/form-data;' 100 | }, 101 | timeout: 20000 102 | }) 103 | if (!imgUrl) { 104 | ErrorTip('图片上传失败') 105 | return 106 | } 107 | // 图片信息绑定到节点上 108 | this.content[nodeId].imgInfo = { 109 | url: imgUrl, 110 | width: 250, 111 | height: (250 * height) / width 112 | } 113 | await this.setContent(this.content) 114 | } 115 | }, 116 | persist: { 117 | enabled: false 118 | } 119 | }) 120 | 121 | function flatToTree(data) { 122 | if (!data) return null 123 | const values = Object.values(data) 124 | const treeData = values.filter(item => { 125 | const { _children, id } = item 126 | if (_children.length) { 127 | item._children = values.filter(e => { 128 | return id === e.parent 129 | }) 130 | } else { 131 | item.children = values.filter(e => { 132 | return id === e.parent 133 | }) 134 | } 135 | return item.parent === '-1' 136 | }) 137 | return treeData[0] 138 | } 139 | 140 | export default useMapStore 141 | -------------------------------------------------------------------------------- /src/store/user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 用户相关状态 3 | */ 4 | import { defineStore } from 'pinia' 5 | import { userApi } from '@/hooks/http' 6 | 7 | const useUserStore = defineStore('user', { 8 | state: () => ({ 9 | token: undefined, 10 | user: undefined 11 | }), 12 | getters: { 13 | getUser: state => state.user, 14 | getToken: state => state.token || localStorage.getItem('token') 15 | }, 16 | actions: { 17 | async login(payload) { 18 | const { isLogin, loginForm } = payload 19 | let data 20 | if (isLogin) { 21 | data = await userApi.login(loginForm) 22 | } else { 23 | data = await userApi.register(loginForm) 24 | } 25 | this.setUser(data) 26 | }, 27 | setUser(data) { 28 | if (!data) return 29 | this.token = data?.token 30 | // ? 更安全的做法:不用[token]关键字 31 | localStorage.setItem('token', this.token) 32 | this.user = data?.user 33 | }, 34 | logout() { 35 | this.token = undefined 36 | this.user = undefined 37 | localStorage.clear() 38 | }, 39 | async updateUser(data) { 40 | const formData = new FormData() 41 | formData.append('user', encodeURIComponent(JSON.stringify(data.user))) 42 | if (data.file) { 43 | formData.append('file', data.file) 44 | } 45 | const res = await userApi.updateUser({ 46 | data: formData, 47 | headers: { 48 | 'Content-Type': 'multipart/form-data;' 49 | }, 50 | timeout: 20000 51 | }) 52 | this.user = res 53 | } 54 | }, 55 | persist: { 56 | enabled: true, 57 | strategies: [ 58 | { 59 | key: 'zmindmap_user', 60 | storage: localStorage 61 | } 62 | ] 63 | } 64 | }) 65 | 66 | export default useUserStore 67 | -------------------------------------------------------------------------------- /src/store/website.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 网站页面相关状态 3 | */ 4 | import { defineStore } from 'pinia' 5 | import { websiteApi } from '@/hooks/http' 6 | 7 | const useWebsiteStore = defineStore({ 8 | id: 'website', 9 | state: () => ({ 10 | // 文件列表展示方式 11 | showTable: false, 12 | // 主题模式 13 | isDark: false, 14 | // 侧边栏是否折叠 15 | siderCollapse: true, 16 | // 导图风格 17 | styles: { 18 | colorList: [], 19 | mapList: [], 20 | markerList: [] 21 | } 22 | }), 23 | actions: { 24 | toggleShowTable() { 25 | this.showTable = !this.showTable 26 | }, 27 | toggleSiderCollapse() { 28 | this.siderCollapse = !this.siderCollapse 29 | }, 30 | switchMapStyle(id) { 31 | this.mapStyle = id 32 | }, 33 | switchMapColor(id) { 34 | this.mapColor = id 35 | }, 36 | toggleDarkMode() { 37 | this.isDark = !this.isDark 38 | const mode = this.isDark ? 'dark' : 'light' 39 | window.document.documentElement.setAttribute('data-theme', mode) 40 | }, 41 | async fetchMapStyles() { 42 | const data = await websiteApi.fetchMapStyles() 43 | this.styles = data 44 | } 45 | }, 46 | persist: { 47 | enabled: true, 48 | strategies: [ 49 | { 50 | key: 'zmindmap_website', 51 | storage: localStorage 52 | // paths: ['name', 'age'] 53 | } 54 | ] 55 | } 56 | }) 57 | export default useWebsiteStore 58 | -------------------------------------------------------------------------------- /src/views/Edit.vue: -------------------------------------------------------------------------------- 1 | 30 | 80 | 199 | -------------------------------------------------------------------------------- /src/views/Folder/components/BreadCrumb.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 41 | 42 | 76 | -------------------------------------------------------------------------------- /src/views/Folder/index.vue: -------------------------------------------------------------------------------- 1 | 67 | 122 | 123 | 280 | -------------------------------------------------------------------------------- /src/views/Home/components/Sider.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 68 | 69 | 146 | -------------------------------------------------------------------------------- /src/views/Intro.vue: -------------------------------------------------------------------------------- 1 | 2 | 52 | 86 | 222 | -------------------------------------------------------------------------------- /src/views/Login/components/Qrcode.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 117 | 118 | 195 | -------------------------------------------------------------------------------- /src/views/Login/index.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 131 | 132 | 215 | -------------------------------------------------------------------------------- /src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 8 | 15 | 30 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const AutoImport = require('unplugin-auto-import/webpack') 4 | const Components = require('unplugin-vue-components/webpack') 5 | const SentryWebpackPlugin = require('@sentry/webpack-plugin') 6 | const SpeedMeasurePlugin = require('speed-measure-webpack-plugin') 7 | const HardSourceWebpackPlugin = require('hard-source-webpack-plugin') 8 | const { ESBuildMinifyPlugin } = require('esbuild-loader') 9 | 10 | const { ElementPlusResolver } = require('unplugin-vue-components/resolvers') 11 | 12 | const IS_PROD = process.env.NODE_ENV === 'production' 13 | const getAliasPath = dir => path.join(__dirname, dir) 14 | 15 | module.exports = { 16 | productionSourceMap: false, 17 | lintOnSave: !IS_PROD, 18 | publicPath: IS_PROD ? 'https://cdn.kimjisoo.cn/' : '/', 19 | pages: { 20 | index: { 21 | entry: './src/main.js', 22 | template: './public/index.html', 23 | title: 'ZMind思维导图', 24 | chunks: [ 25 | 'chunk-vendors', 26 | 'chunk-vendors-2', 27 | 'chunk-ele', 28 | 'index' 29 | ] 30 | } 31 | }, 32 | chainWebpack: config => { 33 | config.when(!IS_PROD, config => { 34 | const rule = config.module.rule('js') 35 | // 清理自带的 babel-loader 36 | rule.uses.clear(); 37 | // 添加 esbuild-loader 38 | rule 39 | .use('esbuild-loader') 40 | .loader('esbuild-loader') 41 | .options({ 42 | target: 'es2015' 43 | }) 44 | // 删除底层 terser, 换用esbuild-minimize-plugin 45 | config.optimization.minimizers.delete('terser') 46 | // 使用 esbuild 优化 css 压缩 47 | config.optimization 48 | .minimizer('esbuild') 49 | .use(ESBuildMinifyPlugin, [{ minify: true, css: true }]) 50 | }) 51 | 52 | config.when(IS_PROD, config => { 53 | // 移除prefetch插件 减少对首页加载的带宽占用 54 | // config.plugins.delete('prefetch') 55 | // config.optimization.runtimeChunk({ 56 | // // https://webpack.docschina.org/configuration/optimization/#optimizationruntimechunk 57 | // name: (entrypoint) => `runtime~${entrypoint.name}`, 58 | // }), 59 | config.optimization.splitChunks({ 60 | cacheGroups: { 61 | // 其他的第三方库集中在一起 62 | vendors: { 63 | name: 'chunk-vendors', 64 | test: /[\\/]node_modules[\\/]/, 65 | chunks: 'initial', 66 | priority: 2, 67 | reuseExistingChunk: true, 68 | enforce: true 69 | }, 70 | // @sentry|@vueuse|cropperjs 很大 单独分包 71 | vendors2: { 72 | name: 'chunk-vendors-2', 73 | test: /[\\/]node_modules[\\/]@sentry|@vueuse|cropperjs/, 74 | chunks: 'initial', 75 | priority: 3, 76 | reuseExistingChunk: true, 77 | enforce: true 78 | }, 79 | // 只有index入口用 且很大 需要单独打包 80 | elementui: { 81 | name: 'chunk-ele', 82 | test: /[\\/]node_modules[\\/]@?element-plus[\\/]/, 83 | chunks: 'initial', 84 | priority: 5, 85 | reuseExistingChunk: true, 86 | enforce: true 87 | } 88 | } 89 | }), 90 | config.optimization.minimizer('terser').tap(args => { 91 | // 移除 debugger 92 | args[0].terserOptions.compress.drop_debugger = true 93 | // 移除 console.log 94 | args[0].terserOptions.compress.pure_funcs = ['console.log'] 95 | // 移除 注释 96 | args[0].terserOptions.output = { 97 | comments: false 98 | } 99 | return args 100 | }) 101 | }) 102 | const svgRule = config.module.rule('svg') 103 | svgRule.uses.clear() 104 | svgRule 105 | .test(/\.svg$/) 106 | .include.add(path.resolve(__dirname, 'src/assets/pic')) 107 | .end() 108 | .use('svg-sprite-loader') 109 | .loader('svg-sprite-loader') 110 | .options({ 111 | symbolId: 'icon-[name]' 112 | }) 113 | .end() 114 | .use('svgo-loader') 115 | .loader('svgo-loader') 116 | .options({ 117 | name: 'removeAttrs', 118 | params: { attrs: 'fill' } 119 | }) 120 | .end() 121 | config.module 122 | .rule('worker') 123 | .test(/\.worker\.js$/) 124 | .use('worker-loader') 125 | .loader('worker-loader') 126 | .end() 127 | // 解决:worker 热更新问题 128 | config.module.rule('js').exclude.add(/\.worker\.js$/) 129 | config.resolve.alias 130 | .set('@', getAliasPath('src')) 131 | .set('assets', getAliasPath('src/assets')) 132 | .set('hooks', getAliasPath('src/hooks')) 133 | .set('views', getAliasPath('src/views')) 134 | .set('store', getAliasPath('src/store')) 135 | .set('components', getAliasPath('src/components')) 136 | }, 137 | configureWebpack: { 138 | resolveLoader: { 139 | modules: ['node_modules', path.resolve(__dirname, 'loaders')], 140 | }, 141 | module: { 142 | rules: [ 143 | { 144 | test: /\.mjs$/, 145 | include: /node_modules/, 146 | type: 'javascript/auto' 147 | } 148 | ] 149 | }, 150 | plugins: [ 151 | new HardSourceWebpackPlugin(), 152 | new webpack.DefinePlugin({ 153 | BASE_API_URL: IS_PROD 154 | ? JSON.stringify('https://mapapi.kimjisoo.cn') 155 | : JSON.stringify('http://localhost:3003') 156 | }), 157 | AutoImport({ 158 | resolvers: [ElementPlusResolver()] 159 | }), 160 | Components({ 161 | resolvers: [ElementPlusResolver()] 162 | }), 163 | require('unplugin-element-plus/webpack')({}), 164 | new SentryWebpackPlugin({ 165 | dryRun: !IS_PROD, // 只有生成环境才上传source map 166 | include: 'dist', 167 | ignore: ['node_modules', 'vue.config.js'] 168 | }), 169 | //* 实际上 七牛cdn对打包好的源文件自动进行了gzip 170 | // new CompressionWebpackPlugin({ 171 | // filename: '[path].gz[query]', 172 | // algorithm: 'gzip', 173 | // test: /\.js$|\.css$|\.html$|\.ttf$|\.eot$|\.woff$/, 174 | // threshold: 10240, 175 | // minRatio: 0.8, 176 | // deleteOriginalAssets: false 177 | // }), 178 | new SpeedMeasurePlugin() 179 | ] 180 | } 181 | } 182 | --------------------------------------------------------------------------------