浏览代码

feat: 完成树节点布局与节点间连线

Jinyao 5 年之前
父节点
当前提交
9e58d8889e
共有 4 个文件被更改,包括 356 次插入2 次删除
  1. 1 0
      .eslintrc.js
  2. 231 0
      src/components/Tree.vue
  3. 71 0
      src/components/TreeNode.vue
  4. 53 2
      src/views/Project/Schedule.vue

+ 1 - 0
.eslintrc.js

@@ -16,5 +16,6 @@ module.exports = {
     'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
     'import/prefer-default-export': 'off',
     'max-len': ['error', { code: 140, ignoreTrailingComments: true }],
+    'no-param-reassign': [2, { props: false }],
   },
 };

+ 231 - 0
src/components/Tree.vue

@@ -0,0 +1,231 @@
+<template>
+<div id="tree-container" :style="`height:${HEIGHT}px`">
+  <div id="node-container" :style="`height:${HEIGHT}px; width:${WIDTH}px`">
+    <TreeNode
+      v-for="requirement in data"
+      :key="requirement.id"
+      v-bind="requirement"
+      :ref="`treenode-${requirement.id}`"
+    />
+  </div>
+  <canvas id="line-drawer" :height="HEIGHT" :width="WIDTH"></canvas>
+</div>
+</template>
+
+<script>
+import TreeNode from './TreeNode.vue';
+
+// 容器宽高
+const HEIGHT = 600;
+const WIDTH = 1100;
+
+export default {
+  name: 'Tree',
+  components: { TreeNode },
+  props: {
+    data: Array,
+  },
+  data() {
+    return {
+      HEIGHT,
+      WIDTH,
+      parent2children: {},
+      depth2node: {},
+      layout: {},
+    };
+  },
+  mounted() {
+    for (let i = 0; i < this.data.length; i += 1) {
+      const parentid = this.data[i].parent;
+      if (!Object.prototype.hasOwnProperty.call(this.parent2children, parentid)) {
+        this.parent2children[parentid] = [];
+      }
+      this.parent2children[parentid].push(this.data[i]);
+    }
+    let depth = 1;
+    let parentList = [null];
+    for (;depth < 5; depth += 1) {
+      for (let i = 0; i < this.data.length; i += 1) {
+        if (parentList.includes(this.data[i].parent)) {
+          if (!Object.prototype.hasOwnProperty.call(this.depth2node, depth)) {
+            this.depth2node[depth] = [];
+          }
+          this.depth2node[depth].push(this.data[i]);
+        }
+      }
+      parentList = this.depth2node[depth].map((node) => node.id);
+    }
+
+    this.calculateLayout();
+    this.locateAndDraw();
+  },
+  methods: {
+    calculateLayout() {
+      const MARGIN_TOP = 40;
+      const MARGIN_LEFT = 100;
+      const COL_WIDTH = (WIDTH - MARGIN_LEFT) / 4;
+
+      for (let depth = 4; depth > 0; depth -= 1) {
+        const offsetLeft = MARGIN_LEFT + (COL_WIDTH * (depth * 2 - 1)) / 2;
+        let offsetTop = MARGIN_TOP;
+        for (let i = 0; i < this.depth2node[depth].length; i += 1) {
+          const treenode = this.$refs[`treenode-${this.depth2node[depth][i].id}`];
+          const { width, height } = treenode.getTreeNodeSize();
+          const offsetX = offsetLeft - width / 2;
+          let offsetY;
+          if (depth === 4) {
+            offsetY = offsetTop;
+            offsetTop += height + MARGIN_TOP;
+          } else if (Object.prototype.hasOwnProperty.call(this.parent2children, this.depth2node[depth][i].id)) {
+            const childrenList = this.parent2children[this.depth2node[depth][i].id].map((node) => node.id);
+            offsetY = childrenList.reduce(
+              (sum, curr) => sum + this.layout[curr].y + this.layout[curr].height / 2,
+              0,
+            ) / childrenList.length - height / 2;
+          } else {
+            offsetY = offsetTop;
+          }
+          offsetTop = offsetY + height + MARGIN_TOP;
+          this.layout[this.depth2node[depth][i].id] = {
+            x: offsetX,
+            y: offsetY,
+            width,
+            height,
+          };
+        }
+      }
+    },
+    locateAndDraw() {
+      // 定位TreeNodeContainer
+      // eslint-disable-next-line no-restricted-syntax
+      for (const id of Object.keys(this.layout)) {
+        const { x, y } = this.layout[id];
+        this.$refs[`treenode-${id}`].setTreeNodeOffset(x, y);
+      }
+      // 在canvas上绘制节点间连线
+      const ctx = document.getElementById('line-drawer').getContext('2d');
+      ctx.clearRect(0, 0, WIDTH, HEIGHT);
+
+      ctx.lineWidth = 1;
+      ctx.strokeStyle = '#bbbbbb';
+
+      const kvs = Object.entries(this.parent2children);
+      // eslint-disable-next-line no-restricted-syntax
+      for (const [parent, children] of kvs) {
+        if (parent === 'null') {
+          // eslint-disable-next-line no-continue
+          continue;
+        }
+        const {
+          x: startX, y: startY, width: startW, height: startH,
+        } = this.layout[parent];
+        const start = {
+          x: startX + startW,
+          y: startY + startH / 2,
+        };
+        // eslint-disable-next-line no-restricted-syntax
+        for (const child of children) {
+          const {
+            x: endX, y: endY, height: endH,
+          } = this.layout[child.id];
+          const end = {
+            x: endX,
+            y: endY + endH / 2,
+          };
+          ctx.moveTo(start.x, start.y);
+          ctx.lineTo(end.x, end.y);
+          ctx.stroke();
+        }
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+#tree-container {
+  display: flex;
+  justify-content:center;
+}
+#node-container {
+  position:absolute;
+  z-index: 101;
+}
+#line-drawer {
+  position: absolute;
+}
+.jsmind-inner {
+  position:relative;
+  overflow:auto;
+  width:100%;
+  height:100%;
+  user-select:none;
+}
+
+canvas {
+  position:absolute;
+  z-index:1;
+}
+
+jmnodes {
+  position:absolute;
+  z-index:2;
+  background-color:rgba(0,0,0,0);
+}
+jmnode {
+  position:absolute;
+  cursor:default;
+  max-width:400px;
+  white-space:nowrap;
+  overflow:hidden;
+  text-overflow:ellipsis;
+
+  padding:10px;
+  background-color:#fff;
+  color:#333;
+  border-radius:1px;
+  border: 1px solid #333;
+  font:16px/1.125 Verdana,Arial,Helvetica,sans-serif;
+
+  &:hover {
+    box-shadow:2px 2px 4px #333;
+    background-color:#ebebeb;
+    color:#333;
+  }
+
+  &.selected {
+    background-color:#11f;
+    color:#fff;
+    box-shadow:2px 2px 8px #000;
+  }
+
+  &.root {
+    font-size:24px;
+  }
+}
+jmexpander {
+  position:absolute;
+  width:11px;
+  height:11px;
+  display:block;
+  overflow:hidden;
+  line-height:12px;
+  font-size:12px;
+  text-align:center;
+  border-radius:6px;
+  border-width:1px;
+  border-style:solid;
+  cursor:pointer;
+
+  border-color:gray;
+
+  &:hover {
+    border-color:#000;
+  }
+}
+
+@media screen and (max-device-width: 1024px) {
+    jmnode{padding:5px;border-radius:3px;font-size:14px;}
+    jmnode.root{font-size:21px;}
+}
+</style>

+ 71 - 0
src/components/TreeNode.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="tree-node-container" ref="node">
+    <div class="tree-node" @click="openDrawer(id)">
+      <div class="node-state">{{state}}</div>
+      <div class="node-title">{{title}}</div>
+    </div>
+    <div class="node-postfix">
+      +
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'TreeNode',
+  props: {
+    id: Number,
+    state: String,
+    title: String,
+  },
+  methods: {
+    setTreeNodeOffsetX(x) {
+      this.$refs.node.style.left = `${x}px`;
+    },
+    setTreeNodeOffsetY(y) {
+      this.$refs.node.style.top = `${y}px`;
+    },
+    setTreeNodeOffset(x, y) {
+      this.$refs.node.style.left = `${x}px`;
+      this.$refs.node.style.top = `${y}px`;
+    },
+    getTreeNodeSize() {
+      return {
+        height: this.$refs.node.offsetHeight,
+        width: this.$refs.node.offsetWidth,
+      };
+    },
+    openDrawer(id) {
+      // TODO: 打开详细信息抽屉
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+.tree-node-container {
+  position: absolute;
+  height: 140px;
+  width: 20%;
+
+  user-select: none;
+  & .node-postfix {
+    position: absolute;
+    left: 160px;
+    height: 120px;
+    display: none;
+  }
+
+  &:hover .node-postfix {
+    display: block;
+  }
+}
+.tree-node {
+  box-sizing: border-box;
+  border-radius: 2px;
+  border: 1px solid #3f3f3f;
+  height: 120px;
+  width: 100%;
+}
+
+</style>

+ 53 - 2
src/views/Project/Schedule.vue

@@ -1,10 +1,61 @@
 <template>
-  project-schedule
+  <Tree :data="requirements" @change="handleTreeChange"></Tree>
+  <button @click="submit">提交</button>
 </template>
 
 <script>
-export default {
+import Tree from '@/components/Tree.vue';
 
+export default {
+  name: 'Schedule',
+  components: { Tree },
+  data() {
+    return {
+      requirements: [
+        {
+          id: 1,
+          title: 't1',
+          parent: null,
+          state: '未开始',
+        },
+        {
+          id: 2,
+          title: 't2',
+          parent: 1,
+          state: '未开始',
+        },
+        {
+          id: 3,
+          title: 't3',
+          parent: 1,
+          state: '未开始',
+        },
+        {
+          id: 4,
+          title: 't4',
+          parent: 2,
+          state: '未开始',
+        },
+        {
+          id: 5,
+          title: 't5',
+          parent: 2,
+          state: '进行中',
+        },
+        {
+          id: 6,
+          title: 't6',
+          parent: 4,
+          state: '已完成',
+        },
+      ],
+    };
+  },
+  methods: {
+    submit() {
+      // TODO: 提交
+    },
+  },
 };
 </script>