├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package.json ├── public └── favicon.ico ├── screenshot ├── vue_drag_gantt_1.gif ├── vue_drag_gantt_2.gif ├── vue_drag_gantt_3.gif ├── vue_drag_gantt_4.gif └── vue_drag_gantt_5.gif ├── src ├── App.vue ├── api │ ├── dataTemplate.json │ └── mock-data.js ├── components │ ├── demo │ │ ├── checkAdjust.vue │ │ ├── menu-item.vue │ │ └── task-item.vue │ └── v-gantt │ │ ├── block-group │ │ └── block-group.vue │ │ ├── block-row │ │ └── block-row.vue │ │ ├── gantt.scss │ │ ├── gantt.vue │ │ ├── index.js │ │ ├── left-bar │ │ └── index.vue │ │ ├── mark-line │ │ ├── current-time.vue │ │ └── index.vue │ │ ├── mixin │ │ └── dynamic-render.js │ │ └── time-line │ │ └── index.vue ├── main.js ├── store │ └── index.js ├── style │ ├── index.sass │ └── reset.scss └── utils │ ├── gtUtils.js │ ├── timeLineUtils.js │ └── tool.js └── vite.config.js /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-20.04 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Install and Build 16 | run: | 17 | npm install 18 | npm run build 19 | - name: Deploy 20 | uses: JamesIves/github-pages-deploy-action@v4.3.3 21 | with: 22 | branch: gh-pages 23 | folder: dist 24 | single-commit: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | package-lock.json 14 | *.local 15 | 16 | # Editor directories and files 17 | webstorm.config.js 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 liyang5945 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 |

vue-drag-gantt-chart

2 | 3 | ## 4 | 5 | 基于[Vue-Gantt-chart](https://github.com/w1301625107/Vue-Gantt-chart) 修改而来,改动如下: 6 | 7 | - 样式调整,添加顶部部的时间刻度格和左侧日期。滚动插件使用 [iscroll](https://github.com/cubiq/iscroll) 实现,使滚动条样式在各浏览器下保持一致,支持鼠标按住拖动,类似手机上的按住滚动效果。 8 | 9 | - 数据分组:不同属性的甘特行可以分组,分组后数据渲染也是动态的,即只渲染浏览器视口内的数据,我本机测试万级数据(500行25列)轻微卡顿。 10 | 11 | - 数据搜索:搜索后高亮显示结果,并滚动到相应任务位置,若搜索到多个结果,继续点搜索按钮跳转到下一个结果。 12 | 13 | - 甘特块拖拽调整:基于浏览器原生拖拽事件实现,不同行之间的甘特块可以拖拽调整,调整时可以做一些校验,代码里暂时只做了时间校验,拖拽后默认会有一个黑色阴影块显示原来的任务,在配置项里可以设置为不显示,调整确认弹窗也可以选择显示或不显示(默认不显示)。 14 | 15 | - 右键菜单:若想要调整的行竖向间距过大不方便拖拽时,可使用右键菜单调整任务,可以选择复制或交换。 16 | 17 | 18 | ### demo: [在线演示](https://liyang5945.github.io/vue-drag-gantt-chart) 19 | 20 | ### 动图演示 21 | 拖拽移动 22 | 23 | ![](screenshot/vue_drag_gantt_1.gif) 24 | 25 | 数据分组 26 | 27 | ![](screenshot/vue_drag_gantt_2.gif) 28 | 29 | 搜索 30 | 31 | ![](screenshot/vue_drag_gantt_3.gif) 32 | 33 | 拖拽调整任务 34 | 35 | ![](screenshot/vue_drag_gantt_4.gif) 36 | 37 | 右键菜单调整任务 38 | 39 | ![](screenshot/vue_drag_gantt_5.gif) 40 | 41 | 42 | 数据格式,每一行的数据如下,rawIndex这个字段是每一行的原始顺序,用来确定垂直方向的位置(计算绝对定位的top值),gtArray里面是每一个小块的数据。 43 | 44 | ```json 45 | 46 | { 47 | "rawIndex": 2, 48 | "id": "JHR725ST", 49 | "type": "🚄", 50 | "speed": 88, 51 | "name": "警官号", 52 | "colorPair": { 53 | "dark": "rgb(247, 167, 71,0.8)", 54 | "light": "rgb(247, 167, 71,0.1)" 55 | }, 56 | "gtArray": [ 57 | { 58 | "id": "UM4366", 59 | "passenger": 40, 60 | "start": "Tue, 31 May 2022 21:00:28 GMT", 61 | "end": "Wed, 01 Jun 2022 02:00:28 GMT", 62 | "type": "🚄", 63 | "parentId": "JHR725ST" 64 | }, 65 | { 66 | "id": "RA6062", 67 | "passenger": 120, 68 | "start": "Wed, 01 Jun 2022 06:00:28 GMT", 69 | "end": "Wed, 01 Jun 2022 10:00:28 GMT", 70 | "type": "🚄", 71 | "parentId": "JHR725ST" 72 | }, 73 | { 74 | "id": "TR8476", 75 | "passenger": 52, 76 | "start": "Wed, 01 Jun 2022 15:00:28 GMT", 77 | "end": "Wed, 01 Jun 2022 20:00:28 GMT", 78 | "type": "🚄", 79 | "parentId": "JHR725ST" 80 | }, 81 | { 82 | "id": "VX5715", 83 | "passenger": 44, 84 | "start": "Wed, 01 Jun 2022 23:00:28 GMT", 85 | "end": "Thu, 02 Jun 2022 04:00:28 GMT", 86 | "type": "🚄", 87 | "parentId": "JHR725ST" 88 | } 89 | ] 90 | } 91 | 92 | ``` 93 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vue-drag-gantt-chart 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-drag-gantt-chart", 3 | "private": true, 4 | "version": "1.0.0", 5 | "scripts": { 6 | "dev": "vite --host", 7 | "build": "vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "axios": "^1.3.5", 12 | "dayjs": "^1.11.7", 13 | "element-ui": "^2.15.13", 14 | "iscroll": "^5.2.0", 15 | "lodash": "^4.17.21", 16 | "mockjs": "^1.1.0", 17 | "resize-observer-polyfill": "^1.5.1", 18 | "v-contextmenu": "^2.9.0", 19 | "vue": "2.7.14", 20 | "vuex": "^3.6.2" 21 | }, 22 | "devDependencies": { 23 | "@vitejs/plugin-vue2": "^2.2.0", 24 | "sass": "^1.58.3", 25 | "vite": "^4.2.1", 26 | "unplugin-vue-components": "^0.24.1", 27 | "vue-template-compiler": "^2.7.14" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyang5945/vue-drag-gantt-chart/c10f00c56abe2e990061624ad74feed2e6fe8227/public/favicon.ico -------------------------------------------------------------------------------- /screenshot/vue_drag_gantt_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyang5945/vue-drag-gantt-chart/c10f00c56abe2e990061624ad74feed2e6fe8227/screenshot/vue_drag_gantt_1.gif -------------------------------------------------------------------------------- /screenshot/vue_drag_gantt_2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyang5945/vue-drag-gantt-chart/c10f00c56abe2e990061624ad74feed2e6fe8227/screenshot/vue_drag_gantt_2.gif -------------------------------------------------------------------------------- /screenshot/vue_drag_gantt_3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyang5945/vue-drag-gantt-chart/c10f00c56abe2e990061624ad74feed2e6fe8227/screenshot/vue_drag_gantt_3.gif -------------------------------------------------------------------------------- /screenshot/vue_drag_gantt_4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyang5945/vue-drag-gantt-chart/c10f00c56abe2e990061624ad74feed2e6fe8227/screenshot/vue_drag_gantt_4.gif -------------------------------------------------------------------------------- /screenshot/vue_drag_gantt_5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyang5945/vue-drag-gantt-chart/c10f00c56abe2e990061624ad74feed2e6fe8227/screenshot/vue_drag_gantt_5.gif -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 157 | 158 | 600 | -------------------------------------------------------------------------------- /src/api/dataTemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "rawIndex": 2, 3 | "id": "JHR725ST", 4 | "type": "🚄", 5 | "speed": 88, 6 | "name": "警官号", 7 | "colorPair": { 8 | "dark": "rgb(247, 167, 71,0.8)", 9 | "light": "rgb(247, 167, 71,0.1)" 10 | }, 11 | "gtArray": [ 12 | { 13 | "id": "UM4366", 14 | "passenger": 40, 15 | "start": "Tue, 31 May 2022 21:00:28 GMT", 16 | "end": "Wed, 01 Jun 2022 02:00:28 GMT", 17 | "type": "🚄", 18 | "parentId": "JHR725ST" 19 | }, 20 | { 21 | "id": "RA6062", 22 | "passenger": 120, 23 | "start": "Wed, 01 Jun 2022 06:00:28 GMT", 24 | "end": "Wed, 01 Jun 2022 10:00:28 GMT", 25 | "type": "🚄", 26 | "parentId": "JHR725ST" 27 | }, 28 | { 29 | "id": "TR8476", 30 | "passenger": 52, 31 | "start": "Wed, 01 Jun 2022 15:00:28 GMT", 32 | "end": "Wed, 01 Jun 2022 20:00:28 GMT", 33 | "type": "🚄", 34 | "parentId": "JHR725ST" 35 | }, 36 | { 37 | "id": "VX5715", 38 | "passenger": 44, 39 | "start": "Wed, 01 Jun 2022 23:00:28 GMT", 40 | "end": "Thu, 02 Jun 2022 04:00:28 GMT", 41 | "type": "🚄", 42 | "parentId": "JHR725ST" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/api/mock-data.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import Mock from "mockjs"; 3 | 4 | const colorList = [ 5 | "(252, 105, 100)", 6 | "(247, 167, 71)", 7 | "(116, 202, 90)", 8 | "(83, 186, 241)", 9 | "(208, 142, 2231)" 10 | ]; 11 | const nameList = "希望号,飞翼号,光明号,窥探号,力神号,警官号,闪电流星号,博士号,霹雳火神号,狙击手号,希望之光号,南海忍者号,火速E3号,山神号,安全卫士号,铁锤号,寿星号,星星号,罗曼斯卡,欲望号,霹雳雷电号,消防号,欧洲之星号".split( 12 | "," 13 | ); 14 | 15 | const typeList = "🚅,🚈,🚄".split(","); 16 | 17 | const Random = Mock.Random; 18 | let colNum = 10; 19 | let times = [new Date(2000, 10, 10, 10, 10), new Date(2000, 10, 11, 10, 10)]; 20 | 21 | 22 | function generateRow() { 23 | let rowId = "JHR" + 24 | Random.natural(100, 999) + 25 | Random.character("upper") + 26 | Random.character("upper"); 27 | let rowType = Random.pick(typeList); 28 | let rowSpeed = Random.natural(0, 200); 29 | let template = { 30 | name: () => Random.pick(nameList), 31 | id: rowId, 32 | type: rowType, 33 | speed: rowSpeed, 34 | colorPair: () => { 35 | let a = "rgb" + Random.pick(colorList); 36 | return { 37 | dark: a.replace(")", ",0.8)"), 38 | light: a.replace(")", ",0.1)") 39 | }; 40 | }, 41 | gtArray: () => { 42 | let temp = []; 43 | let i = 0; 44 | let j = Random.natural(colNum - 1, colNum); 45 | let tempStart = dayjs(times[0]); 46 | let tempEnd = dayjs(times[0]); 47 | 48 | while (i < j) { 49 | tempStart = tempEnd.add(Random.natural(1, 6), "hour"); 50 | tempEnd = tempStart.add(Random.natural(2, 6), "hour"); 51 | temp.push({ 52 | id: 53 | Random.character("upper") + 54 | Random.character("upper") + 55 | Random.natural(1000, 9999), 56 | passenger: Random.natural(10, 200), 57 | start: tempStart.toString(), 58 | end: tempEnd.toString(), 59 | type: rowType, 60 | parentId: rowId 61 | }); 62 | 63 | i++; 64 | } 65 | return temp; 66 | } 67 | }; 68 | return Mock.mock(template) 69 | 70 | } 71 | 72 | export function mockDatas(nums, col, t) { 73 | colNum = col; 74 | times = t; 75 | let datas = []; 76 | for (let i = 0, j = Random.natural(nums, nums); i < j; i++) { 77 | datas.push(Object.assign({rawIndex: i}, generateRow())); 78 | } 79 | return datas; 80 | } 81 | -------------------------------------------------------------------------------- /src/components/demo/checkAdjust.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 214 | 215 | 276 | -------------------------------------------------------------------------------- /src/components/demo/menu-item.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 39 | 40 | 68 | -------------------------------------------------------------------------------- /src/components/demo/task-item.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 174 | 175 | 253 | -------------------------------------------------------------------------------- /src/components/v-gantt/block-group/block-group.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 87 | -------------------------------------------------------------------------------- /src/components/v-gantt/block-row/block-row.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /src/components/v-gantt/gantt.scss: -------------------------------------------------------------------------------- 1 | $gray: #ccc; 2 | 3 | $font-gray:#666; 4 | 5 | .gantt { 6 | &-chart { 7 | will-change: transform; 8 | position: relative; 9 | overflow: hidden; 10 | height: 100%; 11 | width: 100%; 12 | } 13 | 14 | &-container{ 15 | width: 100%; 16 | height: 100%; 17 | } 18 | 19 | &-header { 20 | display: flex; 21 | background-color: #fff; 22 | border-top: 1px solid $gray; 23 | border-bottom: 1px solid $gray; 24 | height: 60px; 25 | &-title { 26 | flex: none; 27 | height: 60px; 28 | padding: 15px 0; 29 | color: #666; 30 | text-align: center; 31 | border-right: 1px solid $gray; 32 | } 33 | .date-control{ 34 | height: 30px; 35 | line-height: 30px; 36 | font-size: 14px; 37 | text-align: center; 38 | .btn-date-ctrl{ 39 | display: inline-block; 40 | font-size: 16px; 41 | cursor: pointer; 42 | padding: 2px 3px; 43 | border: none; 44 | background: none; 45 | } 46 | } 47 | &-timeline { 48 | overflow: hidden; 49 | } 50 | &-timeline-container{ 51 | &:before{ 52 | content: ''; 53 | display: inline-block; 54 | width: 1px; 55 | height: 100%; 56 | background-color: $gray; 57 | position: absolute; 58 | top: 0; 59 | left: -1px; 60 | } 61 | } 62 | } 63 | 64 | &-body { 65 | position: relative; 66 | } 67 | 68 | &-timeline { 69 | position: relative; 70 | text-align: center; 71 | display: flex; 72 | 73 | &-day { 74 | overflow: hidden; 75 | font-weight: bold; 76 | color: $font-gray; 77 | border-right: 1px solid #aaa; 78 | border-bottom: 1px solid #ddd; 79 | } 80 | 81 | &-scale { 82 | display: flex; 83 | border-right: 1px solid #aaa; 84 | &>div { 85 | height: 100%; 86 | font-size: 14px; 87 | color: $font-gray; 88 | border-right: 1px solid #ddd; 89 | &:last-child{ 90 | border-right: none; 91 | } 92 | } 93 | } 94 | 95 | // 隐藏第一个时间节点,不然会只显示一半,不好看 96 | &-block:first-child &-scale div:first-child { 97 | visibility: hidden; 98 | } 99 | } 100 | 101 | &-leftbar { 102 | width: 100%; 103 | background: #fff; 104 | color: $font-gray; 105 | font-size: 0.8rem; 106 | position: relative; 107 | &-container { 108 | flex: none; 109 | position: relative; 110 | overflow: hidden; 111 | background: #fff; 112 | border-right: 1px solid $gray; 113 | z-index: 100; 114 | .left-scroll-wrapper { 115 | width: 100%; 116 | position: absolute; 117 | top: 0; 118 | left: 0; 119 | } 120 | } 121 | .gannt-group-menu{ 122 | background-color: rgba(#408984,.8); 123 | border-bottom: 1px solid $gray; 124 | display: flex; 125 | box-sizing: border-box; 126 | overflow: hidden; 127 | height: 100%; 128 | width: 100%; 129 | padding: 0 10px; 130 | border-radius: 8px 0 0 8px; 131 | align-items: center; 132 | color: #ffffff; 133 | .type-title{ 134 | font-size: 12px; 135 | line-height: 16px; 136 | flex-shrink: 0; 137 | } 138 | .btn-toggle{ 139 | display: inline-block; 140 | width: 20px; 141 | height: 20px; 142 | line-height: 20px; 143 | text-align: center; 144 | flex-shrink: 0; 145 | cursor: pointer; 146 | } 147 | .classify-tags{ 148 | flex: 1; 149 | } 150 | .classify-tag{ 151 | display: inline-block; 152 | padding: 0 5px; 153 | line-height: 20px; 154 | height: 20px; 155 | font-size: 14px; 156 | color: #fff; 157 | background-color: #3d847f; 158 | text-align: center; 159 | } 160 | 161 | } 162 | .left-bar-wrapper{ 163 | position: relative; 164 | } 165 | 166 | &-item { 167 | position: absolute; 168 | width: 100%; 169 | } 170 | 171 | &-defalutItem { 172 | width: 100%; 173 | height: 100%; 174 | outline: 1px solid $gray 175 | } 176 | } 177 | 178 | &-table { 179 | display: flex; 180 | width: 100%; 181 | height: 100%; 182 | } 183 | 184 | &-markline-area { 185 | position: absolute; 186 | z-index: 99; 187 | } 188 | 189 | &-markline { 190 | position: absolute; 191 | z-index: 100; 192 | width: 2px; 193 | height: 100vh; 194 | 195 | &-label { 196 | padding: 3px; 197 | float: left; 198 | color: #fff; 199 | font-size: 0.7rem; 200 | } 201 | } 202 | 203 | &-blocks { 204 | background-color: #fff; 205 | &-wrapper { 206 | height: 100%; 207 | overflow: hidden; 208 | position: relative; 209 | background-color: #f1f1f1; 210 | -webkit-touch-callout: none; /* iOS Safari */ 211 | -webkit-user-select: none; /* Chrome/Safari/Opera */ 212 | -moz-user-select: none; /* Firefox */ 213 | -ms-user-select: none; /* Internet Explorer/Edge */ 214 | user-select: none; /* Non-prefixed version, currently 禁止鼠标拖动时 */ 215 | } 216 | } 217 | 218 | &-block { 219 | &-container { 220 | position: relative; 221 | height: 100%; 222 | } 223 | &-top-space{ 224 | background-color: rgba(#408984,.8); 225 | border-bottom: 1px solid $gray; 226 | } 227 | &-row-wrapper{ 228 | position: relative; 229 | } 230 | &-row{ 231 | width: 100%; 232 | border-bottom: 1px solid $gray; 233 | position: absolute; 234 | } 235 | &-item { 236 | position: absolute; 237 | height: 100%; 238 | } 239 | &-defaultBlock { 240 | width: 100%; 241 | height: 100%; 242 | outline: 1px solid $gray; 243 | background: $gray; 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/components/v-gantt/gantt.vue: -------------------------------------------------------------------------------- 1 | 184 | 185 | 645 | 646 | 649 | -------------------------------------------------------------------------------- /src/components/v-gantt/index.js: -------------------------------------------------------------------------------- 1 | import gantt from "./gantt.vue"; 2 | 3 | gantt.version = "__VERSION__"; 4 | gantt.install = function(Vue) { 5 | // // 将其注册为vue的组件,'gantt'是组件名 6 | Vue.component("v-gantt-chart", gantt); 7 | }; 8 | // 新增 9 | if (typeof window !== "undefined" && window.Vue) { 10 | window.Vue.use(gantt); 11 | } 12 | 13 | // 最后将插件导出,并在main.js中通过Vue.use()即可使用插件 14 | export default gantt; 15 | // export const vGanttChart = gantt; 16 | -------------------------------------------------------------------------------- /src/components/v-gantt/left-bar/index.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 94 | -------------------------------------------------------------------------------- /src/components/v-gantt/mark-line/current-time.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 36 | -------------------------------------------------------------------------------- /src/components/v-gantt/mark-line/index.vue: -------------------------------------------------------------------------------- 1 | 19 | 54 | -------------------------------------------------------------------------------- /src/components/v-gantt/mixin/dynamic-render.js: -------------------------------------------------------------------------------- 1 | const dynamicRender = { 2 | props: { 3 | scrollTop: { 4 | type: Number, 5 | required: true 6 | }, 7 | heightOfBlocksWrapper: { 8 | type: Number, 9 | required: true 10 | }, 11 | isOpen: { 12 | type: Boolean, 13 | default: true 14 | }, 15 | cellHeight: { 16 | type: Number, 17 | required: true 18 | }, 19 | datas: { 20 | type: Array, 21 | required: true 22 | }, 23 | // 为 0 时加载全部行, 24 | //预加载的数量,是前后都计算 25 | preload: { 26 | type: Number, 27 | default: 1 28 | }, 29 | totalHeight: { 30 | type: Number, 31 | default: 0 32 | }, 33 | groupIndex: Number 34 | }, 35 | 36 | data() { 37 | return { 38 | //上一次加载的第一个节点 39 | wrapperElement: null, 40 | oldTopIndex: 0, 41 | startRenderNum: 0, 42 | endRenderNum: 0, 43 | topSpace: 0, 44 | renderPosition: [], 45 | top: 0, 46 | bottom: 0 47 | }; 48 | }, 49 | 50 | computed: { 51 | //计算当前屏幕显示的第一行数据的index 52 | showDatas() { 53 | const { startRenderNum, endRenderNum, datas } = this; 54 | return datas.slice(startRenderNum, endRenderNum); 55 | } 56 | }, 57 | 58 | watch: { 59 | scrollTop() { 60 | this.sliceData(); 61 | }, 62 | datas() { 63 | this.sliceData(); 64 | }, 65 | totalHeight() { 66 | this.$nextTick(() => { 67 | this.sliceData(); 68 | }); 69 | }, 70 | heightOfBlocksWrapper() { 71 | this.sliceData(); 72 | }, 73 | cellHeight() { 74 | this.sliceData(); 75 | }, 76 | preload() { 77 | this.sliceData(); 78 | } 79 | }, 80 | 81 | created() { 82 | this.sliceData(); 83 | }, 84 | mounted() { 85 | this.wrapperElement = this.$refs.wrapperElement || null; 86 | }, 87 | 88 | methods: { 89 | /** 90 | * 分割出dom中需要显示的数据 91 | */ 92 | sliceData() { 93 | if (!this.wrapperElement) return false; 94 | 95 | const { 96 | unVisibleHeight, 97 | heightOfBlocksWrapper, 98 | cellHeight, 99 | preload, 100 | datas 101 | } = this; 102 | 103 | const ClientRect = this.wrapperElement.getBoundingClientRect(); 104 | const windowHeight = window.innerHeight; 105 | let startPosition = 0; 106 | let endPosition = 0; 107 | const top = ClientRect.top; 108 | const bottom = ClientRect.bottom; 109 | this.top = top; 110 | this.bottom = bottom; 111 | if (top <= 0) { 112 | startPosition = Math.abs(top) + unVisibleHeight; 113 | endPosition = startPosition + heightOfBlocksWrapper; 114 | if (bottom > unVisibleHeight && bottom <= windowHeight) { 115 | endPosition = startPosition + bottom; 116 | } else if (bottom <= unVisibleHeight) { 117 | this.startRenderNum = 0; 118 | this.endRenderNum = 0; 119 | return; 120 | } 121 | } else if (top > 0 && top <= unVisibleHeight) { 122 | startPosition = unVisibleHeight - top; 123 | endPosition = startPosition + heightOfBlocksWrapper; 124 | } else if (top > unVisibleHeight && top <= windowHeight) { 125 | startPosition = 0; 126 | endPosition = windowHeight - top; 127 | } else if (top > windowHeight) { 128 | this.startRenderNum = 0; 129 | this.endRenderNum = 0; 130 | return; 131 | } 132 | 133 | //没有高度,不需要渲染元素 134 | if (heightOfBlocksWrapper === 0 || cellHeight === 0) { 135 | this.startRenderNum = 0; 136 | this.endRenderNum = 0; 137 | return; 138 | } 139 | 140 | // 为 0 全部渲染 141 | if (preload === 0) { 142 | this.startRenderNum = 0; 143 | this.endRenderNum = datas.length; 144 | return; 145 | } 146 | const startRenderNum = Math.ceil(startPosition / cellHeight) - preload; 147 | this.startRenderNum = startRenderNum < 0 ? 0 : startRenderNum; 148 | 149 | const endRenderNum = Math.ceil(endPosition / cellHeight) + preload; 150 | this.endRenderNum = 151 | endRenderNum > datas.length ? datas.length : endRenderNum; 152 | } 153 | } 154 | }; 155 | 156 | export default dynamicRender; 157 | -------------------------------------------------------------------------------- /src/components/v-gantt/time-line/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 228 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import store from "./store"; 4 | 5 | import vGanttChart from "@/components/v-gantt/index"; 6 | import "@/style/index.sass"; 7 | 8 | import "element-ui/lib/theme-chalk/index.css"; 9 | import loading from "element-ui/lib/loading"; 10 | import Message from "element-ui/lib/message"; 11 | 12 | Vue.prototype.$ELEMENT = { size: "mini" }; 13 | 14 | Vue.use(loading.directive); 15 | Vue.prototype.$loading = loading.service; 16 | Vue.prototype.$message = Message; 17 | 18 | Vue.prototype.$bus = new Vue(); // 事件总线 19 | 20 | import contentMenu from "v-contextmenu/src/index.js"; //右键菜单组件 21 | import "v-contextmenu/dist/index.css"; 22 | 23 | Vue.use(contentMenu); 24 | Vue.use(vGanttChart); 25 | new Vue({ 26 | store, 27 | render: (h) => h(App) 28 | }).$mount("#app"); 29 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | 4 | Vue.use(Vuex); 5 | 6 | export default new Vuex.Store({ 7 | state: { 8 | filterBlockId: "", // 筛选的甘特块id 9 | currentBlock: {}, //当前点击的甘特块 10 | currentRow: {}, //当前点击的甘特行 11 | cutBlock: {}, //剪切的甘特块 12 | cutRow: {}, //剪切的甘特行 13 | targetBlock: {}, //目标甘特块 14 | targetRow: {}, //目标甘特行 15 | handleBlock: {}, //右键操作的甘特块 16 | handleRow: {}, //右键操作的甘特行 17 | showRowList: [], // 筛选后在界面显示的列数据 18 | rawRowList: [], // 列原始数据,用于恢复 19 | showMovedBlock: true, // 是否显示拖拽之前的甘特状态 20 | showDragConfirm: false // 调整任务时是否显示确认弹窗 21 | }, 22 | mutations: { 23 | setFilterBlockId(state, str) { 24 | state.filterBlockId = str; 25 | }, 26 | setCurrentBlock(state, object) { 27 | state.currentBlock = object; 28 | }, 29 | setCurrentRow(state, object) { 30 | state.currentRow = object; 31 | }, 32 | setCutBlock(state, object) { 33 | state.cutBlock = object; 34 | }, 35 | setCutRow(state, object) { 36 | state.cutRow = object; 37 | }, 38 | setTargetBlock(state, object) { 39 | state.targetBlock = object; 40 | }, 41 | setTargetRow(state, object) { 42 | state.targetRow = object; 43 | }, 44 | setHandleBlock(state, object) { 45 | state.handleBlock = object; 46 | }, 47 | setHandleRow(state, object) { 48 | state.handleRow = object; 49 | }, 50 | setShowRowList(state, object) { 51 | state.showRowList = object; 52 | }, 53 | setRawRowList(state, object) { 54 | state.rawRowList = object; 55 | }, 56 | setShowMovedBlock(state, bool) { 57 | state.showMovedBlock = bool; 58 | }, 59 | setShowDragConfirm(state, bool) { 60 | state.showDragConfirm = bool; 61 | } 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /src/style/index.sass: -------------------------------------------------------------------------------- 1 | @import './reset.scss' 2 | html 3 | height: 100% 4 | body 5 | height: 100% 6 | -moz-osx-font-smoothing: grayscale 7 | -webkit-font-smoothing: antialiased 8 | text-rendering: optimizeLegibility 9 | font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif 10 | -moz-user-select: none /*火狐*/ 11 | -webkit-user-select: none /*webkit浏览器*/ 12 | -ms-user-select: none /*IE10*/ 13 | -khtml-user-select: none /*早期浏览器*/ 14 | user-select: none 15 | overflow: hidden 16 | 17 | .clearfix 18 | &:after 19 | visibility: hidden 20 | display: block 21 | font-size: 0 22 | content: " " 23 | clear: both 24 | height: 0 25 | 26 | #app 27 | width: 100% 28 | height: 100% 29 | display: flex 30 | flex-direction: column 31 | 32 | .page-head 33 | min-height: 60px 34 | width: 100% 35 | padding-top: 15px 36 | flex-shrink: 0 37 | display: flex 38 | flex-direction: row 39 | .el-input,.el-button 40 | vertical-align: top 41 | margin-left: 10px 42 | .sub-title 43 | font-size: 16px 44 | height: 28px 45 | line-height: 28px 46 | color: #A0A7B3 47 | margin-right: 20px 48 | margin-left: 20px 49 | flex-shrink: 0 50 | .head-btn-box 51 | flex: 1 52 | .form-title 53 | font-size: 14px 54 | color: #999 55 | margin-left: 10px 56 | 57 | .page-body 58 | overflow: hidden 59 | display: flex 60 | -webkit-box-orient: vertical 61 | -webkit-box-direction: normal 62 | -ms-flex-direction: column 63 | flex-direction: column 64 | -webkit-box-flex: 1 65 | -ms-flex: 1 66 | flex: 1 67 | 68 | 69 | @keyframes colorful 70 | from 71 | background-color: deepskyblue 72 | to 73 | background-color: #39b36b 74 | -------------------------------------------------------------------------------- /src/style/reset.scss: -------------------------------------------------------------------------------- 1 | body, h1, h2, h3, h4, h5, h6, hr, p, blockquote, dl, dt, dd, ul, ol, li, pre, form, fieldset, label, button, input, select, option, textarea, optgroup, table, thead, tbody, tfoot, tr, th, td, div, span, img, a, em, i, iframe, :before, :after { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | body, button, input, select, option, textarea, optgroup, img { 8 | font: 16px/1 "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Heiti SC", "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif; 9 | outline: none; 10 | border: none; 11 | } 12 | 13 | textarea { 14 | resize: none; 15 | overflow: auto; 16 | } 17 | 18 | ul, ol { 19 | list-style: none; 20 | } 21 | 22 | a { 23 | text-decoration: none; 24 | background-color: transparent; 25 | color: #000; 26 | } 27 | 28 | table { 29 | border-collapse: collapse; 30 | border-spacing: 0; 31 | } 32 | 33 | h1, h2, h3, h4, h5, h6 { 34 | font-size: 100%; 35 | font-weight: normal; 36 | } 37 | 38 | article, aside, footer, header, nav, section, figcaption, figure, main, details, menu { 39 | display: block; 40 | box-sizing: border-box; 41 | margin: 0; 42 | padding: 0; 43 | } 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/utils/gtUtils.js: -------------------------------------------------------------------------------- 1 | // import dayjs from 'dayjs' //替换dayjs 兼容性会好一点,但是速度就很慢了,之前测了一下,大概快30倍?有点忘记了 2 | 3 | //缓存 解析值,加速一点点吧 4 | 5 | const cacheParseTime = (function() { 6 | let cacheString = {}; 7 | let cacheValue = {}; 8 | let count = 0; 9 | 10 | return function(timeName, timeString) { 11 | if (cacheString[timeName] !== timeString) { 12 | // 避免缓存过多对象 13 | if (count++ > 10000) { 14 | cacheString = {}; 15 | cacheValue = {}; 16 | } 17 | cacheString[timeName] = timeString; 18 | return (cacheValue[timeName] = parseTime(timeString)); 19 | } 20 | 21 | return cacheValue[timeName]; 22 | }; 23 | })(); 24 | 25 | // pStart 关于缓存这个值是因为getWidthAbout2Times和getPositionOffset通常是前后连续调用,start 值会再两个函数中分别用到一次 26 | 27 | /** 28 | * 根据配置项计算两个时间的在gantt 图中的长度 29 | * 注:时间上start 早, end 晚 30 | * 31 | * @export 32 | * @param {string} start 33 | * @param {string} end 34 | * @param {{scale:number,cellWidth:number}} arg 35 | * @returns number 36 | */ 37 | export function getWidthAbout2Times(start, end, arg) { 38 | const { scale, cellWidth } = arg; 39 | const pStart = cacheParseTime("pStart", start); 40 | const pEnd = parseTime(end); 41 | return (diffTimeByMinutes(pStart, pEnd) / scale) * cellWidth; 42 | } 43 | 44 | /** 45 | * 根据配置项计算 相对于 时间轴起始时间的距离 是 getWidthAbout2Times 的特化 46 | * 注:时间上,time 晚 beginTimeOfTimeLine 早 47 | * 48 | * @export 49 | * @param {string} time 50 | * @param {string} beginTimeOfTimeLine 51 | * @param {{scale:number,cellWidth:number}} arg 52 | * @returns number 53 | */ 54 | export function getPositionOffset(time, beginTimeOfTimeLine, arg) { 55 | const { scale, cellWidth } = arg; 56 | const pTime = cacheParseTime("pStart", time); 57 | const pBeginTimeOfTimeLine = cacheParseTime( 58 | "pBeginTimeOfTimeLine", 59 | beginTimeOfTimeLine 60 | ); 61 | return (diffTimeByMinutes(pBeginTimeOfTimeLine, pTime) / scale) * cellWidth; 62 | } 63 | 64 | function parseTime(time) { 65 | return new Date(time); 66 | } 67 | /** 68 | * 计算两个时间相差的分钟数 69 | * 70 | * @param {string} start 71 | * @param {string} end 72 | * @returns 73 | */ 74 | function diffTimeByMinutes(start, end) { 75 | const diff = end.getTime() - start.getTime(); 76 | return diff / 1000 / 60; 77 | } 78 | 79 | // function parseTime(time){ 80 | // return dayjs(time) 81 | // } 82 | 83 | // function diffTimeByMinutes(start,end){ 84 | // return end.diff(start, "m", true) 85 | // } 86 | -------------------------------------------------------------------------------- /src/utils/timeLineUtils.js: -------------------------------------------------------------------------------- 1 | // import dayjs from "dayjs"; 2 | 3 | export const scaleList = [ 4 | 1, 5 | 2, 6 | 3, 7 | 4, 8 | 5, 9 | 6, 10 | 10, 11 | 12, 12 | 15, 13 | 20, 14 | 30, 15 | 60, 16 | 120, 17 | 180, 18 | 240, 19 | 360, 20 | 720, 21 | 1440 22 | ]; 23 | 24 | export const MINUTE_OF_ONE_DAY = 60 * 24; 25 | 26 | export function isDayScale(scale) { 27 | return scale >= MINUTE_OF_ONE_DAY && scale % MINUTE_OF_ONE_DAY === 0; 28 | } 29 | 30 | /** 31 | * 验证是否合法scale值 32 | * 33 | * @export 34 | * @param {number} scale 35 | * @returns 36 | */ 37 | export function validateScale(scale) { 38 | if (!scaleList.includes(scale) && !isDayScale(scale)) { 39 | throw new RangeError( 40 | `错误的scale值,输入值为${scale},可用的scale值为${scaleList.join( 41 | "," 42 | )},或者为1440的整数倍` 43 | ); 44 | } 45 | return true; 46 | } 47 | 48 | /** 49 | * 根据给出的scale 和 start 时间 计算出用于计算和生成图表的启始时间 50 | * eg:Start 为10:10分 刻度为60,getBeginTimeOfTimeLine函数给出的时间 为 10:00分 51 | * 刻度为5,getBeginTimeOfTimeLine函数给出的时间 为 10:10分 52 | * 刻度为3,getBeginTimeOfTimeLine函数给出的时间 为 10:09分 53 | * 54 | * @export 55 | * @param {dayjs} start 56 | * @param {number} [scale=60] 57 | * @returns {dayjs}计算的启始时间 58 | */ 59 | export function getBeginTimeOfTimeLine(start, scale = 60) { 60 | validateScale(scale); 61 | let timeBlocks; 62 | let result = start.clone(); 63 | let rate = scale / 60; 64 | if (scale > 60) { 65 | timeBlocks = Math.floor(start.hour() / rate); 66 | result = result 67 | .hour(timeBlocks * rate) 68 | .minute(0) 69 | .second(0); 70 | } else { 71 | timeBlocks = Math.floor(start.minute() / scale); 72 | result = result.minute(timeBlocks * scale).second(0); 73 | } 74 | 75 | return result; 76 | } 77 | /** 78 | * 根据所给 scale计算 两个时间差一共可以分成多少个刻度 79 | * 注意: timdStart 并不是实际的开始计算的时间,会通过getBeginTimeOfTimeLine 函数计算出分割开始时间 80 | * 81 | * @export 82 | * @param {dayjs} timeStart 开始时间 83 | * @param {dayjs} timeEnd 结束时间 84 | * @param {number} [scale=60] 分割的刻度 85 | * @returns 时间块数量 86 | */ 87 | export function calcScalesAbout2Times(timeStart, timeEnd, scale = 60) { 88 | if (timeStart.isAfter(timeEnd)) { 89 | throw new TypeError( 90 | "错误的参数顺序,函数calcScalesAbout2Times的第一个时间参数必须大于第二个时间参数" 91 | ); 92 | } 93 | 94 | validateScale(scale); 95 | 96 | let startBlocksTime = getBeginTimeOfTimeLine(timeStart, scale); 97 | let result = 0; 98 | while (!startBlocksTime.isAfter(timeEnd)) { 99 | result++; 100 | startBlocksTime = startBlocksTime.add(scale, "minute"); 101 | } 102 | 103 | return result; 104 | } 105 | -------------------------------------------------------------------------------- /src/utils/tool.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | /** 3 | * 是否没有值 4 | * 5 | * @export 6 | * @param {*} v 7 | * @returns 8 | */ 9 | export function isUndef(v) { 10 | return v === undefined || v === null; 11 | } 12 | /** 13 | * 是否有值 14 | * 15 | * @export 16 | * @param {*} v 17 | * @returns 18 | */ 19 | export function isDef(v) { 20 | return v !== undefined && v !== null; 21 | } 22 | 23 | export function warn(str) { 24 | // eslint-disable-next-line 25 | console.warn(str) 26 | } 27 | 28 | export function noop() {} 29 | 30 | export function debounce(fn, interval = 500, immediate = false) { 31 | //fn为要执行的函数 32 | //interval为等待的时间 33 | //immediate判断是否立即执行 34 | var timeout; //定时器 35 | 36 | return function() { 37 | //返回一个闭包 38 | var context = this, 39 | args = arguments; //先把变量缓存 40 | var later = function() { 41 | //把稍后要执行的代码封装起来 42 | timeout = null; //成功调用后清除定时器 43 | if (!immediate) fn.apply(context, args); //不立即执行时才可以调用 44 | }; 45 | 46 | var callNow = immediate && !timeout; //判断是否立即调用,并且如果定时器存在,则不立即调用 47 | clearTimeout(timeout); //不管什么情况,先清除定时器,这是最稳妥的 48 | timeout = setTimeout(later, interval); //延迟执行 49 | if (callNow) fn.apply(context, args); //如果是第一次触发,并且immediate为true,则立即执行 50 | }; 51 | } 52 | 53 | export function throttle(fn, interval = 100) { 54 | //fn为要执行的函数,interval为延迟时间 55 | var _self = fn, //保存需要被延迟执行的函数引用 56 | timer, //定时器 57 | firstTime = true; //是否第一次调用 58 | return function() { 59 | //返回一个函数,形成闭包,持久化变量 60 | var args = arguments, //缓存变量 61 | _me = this; 62 | if (firstTime) { 63 | //如果是第一次调用,不用延迟执行 64 | _self.apply(_me, args); 65 | return (firstTime = false); 66 | } 67 | if (timer) { 68 | //如果定时器还在,说明上一次延迟执行还没有完成 69 | return false; 70 | } 71 | timer = setTimeout(function() { 72 | //延迟一段时间执行 73 | clearTimeout(timer); 74 | timer = null; 75 | _self.apply(_me, args); 76 | }, interval); 77 | }; 78 | } 79 | 80 | export function checkConflict(blockItem, row, targetBlockItem) { 81 | function convertTimeStr(time) { 82 | return dayjs(time).format("MM-DD HH:mm"); 83 | } 84 | 85 | let currentBlock = blockItem; //要移动的航班 86 | let blockList = row.gtArray.filter((item) => { 87 | return ( 88 | item.movedStatus !== "before" && 89 | (targetBlockItem ? item.id !== targetBlockItem.id : true) 90 | ); 91 | }); //该桥桥位航班列表,过滤拖拽后黑色的 92 | 93 | let conflictList = []; 94 | 95 | /*判断时间冲突*/ 96 | 97 | let blockStart = dayjs(currentBlock.start).valueOf(); 98 | let blockEnd = dayjs(currentBlock.end).valueOf(); 99 | for (let i = 0; i < blockList.length; i++) { 100 | let compareBlock = blockList[i]; 101 | let compareBlockStart = dayjs(compareBlock.start).valueOf(); 102 | let compareBlockEnd = dayjs(compareBlock.end).valueOf(); 103 | if ( 104 | (compareBlockStart < blockStart && blockStart < compareBlockEnd) || //当前甘特块开始时间在目标甘特块里 存在交集 105 | (compareBlockStart < blockEnd && blockEnd < compareBlockEnd) || //当前甘特块结束时间在目标甘特块里 存在交集 106 | (compareBlockStart >= blockStart && blockEnd >= compareBlockEnd) //目标甘特块开始结束时间均在当前甘特块时间里 目标块是当前块的子集 107 | ) { 108 | let timeConflictStr = `${currentBlock.id}:(${convertTimeStr( 109 | currentBlock.start 110 | )}-${convertTimeStr(currentBlock.end)})与目标:${ 111 | compareBlock.id 112 | }(${convertTimeStr(compareBlock.start)}-${convertTimeStr( 113 | compareBlock.end 114 | )})时间冲突`; 115 | 116 | conflictList.push({ 117 | conflictType: "时间校验冲突", 118 | conflictDesc: timeConflictStr, 119 | isIgnore: false 120 | }); 121 | } 122 | } 123 | return { 124 | blockItem: blockItem, 125 | targetRowId: row.id, 126 | blockId: blockItem.id, 127 | adjustType: "移动", 128 | conflictList: conflictList 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { fileURLToPath, URL } from 'node:url' 3 | import Components from 'unplugin-vue-components/vite' 4 | import { ElementUiResolver } from 'unplugin-vue-components/resolvers' 5 | import vue2 from '@vitejs/plugin-vue2' 6 | 7 | export default defineConfig({ 8 | base: "./", 9 | plugins: [ 10 | vue2({ 11 | template: { 12 | compilerOptions: { 13 | preserveWhitespace: true, 14 | whitespace: "preserve" 15 | }, 16 | }, 17 | }), 18 | Components({ 19 | resolvers: [ 20 | ElementUiResolver({ 21 | importStyle: false 22 | }), 23 | ] 24 | }), 25 | ], 26 | resolve: { 27 | alias: { 28 | '@': fileURLToPath(new URL('./src', import.meta.url)) 29 | } 30 | }, 31 | server: { 32 | port: 3001, 33 | } 34 | }) 35 | --------------------------------------------------------------------------------