DEV Community

刘方方
刘方方

Posted on

VTable-Gantt:Powerful and high-performance open-source Gantt chart component

The Basic Concept of Gantt Chart

In project management, the Gantt chart is a commonly used tool for presenting the time arrangement and progress of project tasks.
We split the Gantt chart into the following parts:

  • Left task list: Displays the task list of the project, usually on the left side of the chart.
  • Top time axis: Displays the time range of the project, usually at the top or bottom of the chart.
  • Task bars: Represent the start and end time of each task.
  • Grid lines: Used to separate the time axis and task bars, making the chart clearer.
  • Marker lines: Used to mark important time points.
  • Separation lines: Used to separate the task list and time axis, making the chart clearer. Image description

VTable-Gantt

VTable-Gantt is a powerful Gantt chart drawing tool built based on the VTable table component and the canvas rendering engine VRender, which can help developers easily create and manage Gantt charts.
Core capabilities are as follows:

  1. High performance: Supports fast calculation and rendering of large-scale project data to ensure a smooth user experience.
  2. Flexible layout: Supports customizing the time axis, task bar style, and layout to meet different project management needs.
  3. Powerful interaction: Provides dragging, zooming, and editing functions of tasks to simplify project management operations.
  4. Rich visualization capabilities: Supports custom rendering of information cells and task bars, and provides a tree structure display to enhance the diversity and intuitiveness of data display. ## Obtaining @visactor/vtable-gantt It should be noted that @visactor/vtable-gantt is built based on @visactor/vtable, so you need to install @visactor/vtable first to use @visactor/vtable-gantt. Using the NPM package First, you need to install it in the project root directory using the following command:
使用 npm 安装
npm install @visactor/vtable
npm install @visactor/vtable-gantt

使用 yarn 安装
yarn add @visactor/vtable
yarn add @visactor/vtable-gantt
Enter fullscreen mode Exit fullscreen mode

Introduction to VTableGantt

Import through the NPM package
Use import at the top of the JavaScript file to import vtable-gantt:

import {Gantt} from '@visactor/vtable-gantt';
const ganttInstance = new Gantt(domContainer, option);
Enter fullscreen mode Exit fullscreen mode

Draw a simple Gantt chart
Before drawing, we need to prepare a DOM container with width and height for VTableGantt.

<body>
  <div id="tableContainer" style="width: 600px;height:400px;"></div>
</body>
Enter fullscreen mode Exit fullscreen mode

Next, we create a Gantt instance and pass in the Gantt chart configuration items:

import {Gantt} from '@visactor/vtable-gantt';
const records = [
  {
    id: 1,
    title: 'Task 1',
    developer: 'liufangfang.jane@bytedance.com',
    start: '2024-07-24',
    end: '2024-07-26',
    progress: 31,
    priority: 'P0',
  },
  {
    id: 2,
    title: 'Task 2',
    developer: 'liufangfang.jane@bytedance.com',
    start: '07/24/2024',
    end: '08/04/2024',
    progress: 60,
    priority: 'P0'
  },
  ...
];

const columns = [
  {
    field: 'title',
    title: 'title',
    width: 'auto',
    sort: true,
    tree: true,
    editor: 'input'
  },
  {
    field: 'start',
    title: 'start',
    width: 'auto',
    sort: true,
    editor: 'date-input'
  },
  {
    field: 'end',
    title: 'end',
    width: 'auto',
    sort: true,
    editor: 'date-input'
  }
];
const option = {
  overscrollBehavior: 'none',
  records,
  taskListTable: {
    columns,
  },
  taskBar: {
    startDateField: 'start',
    endDateField: 'end',
    progressField: 'progress'
  },
  timelineHeader: {
    colWidth: 100,
    backgroundColor: '#EEF1F5',
    horizontalLine: {
      lineWidth: 1,
      lineColor: '#e1e4e8'
    },
    verticalLine: {
      lineWidth: 1,
      lineColor: '#e1e4e8'
    },
    scales: [
      {
        unit: 'day',
        step: 1,
        format(date) {
          return date.dateIndex.toString();
        },
        style: {
          fontSize: 20,
          fontWeight: 'bold',
          color: 'white',
          strokeColor: 'black',
          textAlign: 'right',
          textBaseline: 'bottom',
          backgroundColor: '#EEF1F5'
        }
      }
    ]
  },
};
const ganttInstance = new Gantt(document.getElementById("tableContainer"), option);
Enter fullscreen mode Exit fullscreen mode

Demo result:

Image description

The main capabilities of the Gantt chart

Multi-column information display on the left side of the table

The left side of the entire structure of the Gantt chart is a complete table container, so it can support rich column information display and custom rendering capabilities.

import * as VTableGantt from '@visactor/vtable-gantt';
let ganttInstance;
const records = [
 ...
];

const columns = [
  ....
];
const option = {
  overscrollBehavior: 'none',
  records,
  taskListTable: {
    columns,
    tableWidth: 250,
    minTableWidth: 100,
    maxTableWidth: 600,
    theme: {
      headerStyle: {
        borderColor: '#e1e4e8',
        borderLineWidth: 1,
        fontSize: 18,
        fontWeight: 'bold',
        color: 'red',
        bgColor: '#EEF1F5'
      },
      bodyStyle: {
        borderColor: '#e1e4e8',
        borderLineWidth: [1, 0, 1, 0],
        fontSize: 16,
        color: '#4D4D4D',
        bgColor: '#FFF'
      }
    }
  },
  .....
};
ganttInstance = new VTableGantt.Gantt(document.getElementById(CONTAINER_ID), option);

Enter fullscreen mode Exit fullscreen mode

The configuration items of the list table taskListTable, where the following can be configured:

  • Overall width of the left table: Through the tableWidth configuration item, the overall width of the task list table can be set.
  • Column information: Through columns, the column information and width of each column of the task information table can be defined.
  • Style configuration: Through the theme.headerStyle and theme.bodyStyle configuration items, the styles of the table header and body can be set.
  • Width limits: Through the minTableWidth and maxTableWidth configuration items, the minimum and maximum widths of the task list can be set. The complete code can be found at: https://visactor.io/vtable/demo/gantt/gantt-basic For specific configuration, please refer to the official website configuration: https://visactor.io/vtable/option/Gantt#taskListTable The effect is as follows:

Image description

Custom Rendering

Corresponding to the official website demo: https://visactor.io/vtable/demo/gantt/gantt-customLayout, this component provides rich custom rendering capabilities.

Image description

Custom rendering requires understanding the primitive attributes of VRender, and specific details can be referred to in the custom rendering tutorial: https://visactor.io/vtable/guide/gantt/gantt_customLayout

Custom Rendering of Task Bars

Through the taskBar.customLayout configuration item, the rendering method of task bars can be customized. For example:

taskBar: {
      startDateField: 'start',
      endDateField: 'end',
      progressField: 'progress',
      customLayout: args => {
        const colorLength = barColors.length;
        const { width, height, index, startDate, endDate, taskDays, progress, taskRecord, ganttInstance } = args;
        const container = new VRender.Group({
          width,
          height,
          cornerRadius: 30,
          fill: {
            gradient: 'linear',
            x0: 0,
            y0: 0,
            x1: 1,
            y1: 0,
            stops: [
              {
                offset: 0,
                color: barColors0[index % colorLength]
              },
              {
                offset: 0.5,
                color: barColors[index % colorLength]
              },
              {
                offset: 1,
                color: barColors0[index % colorLength]
              }
            ]
          },
          display: 'flex',
          flexDirection: 'row',
          flexWrap: 'nowrap'
        });
        const containerLeft = new VRender.Group({
          height,
          width: 60,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'space-around'
          // fill: 'red'
        });
        container.add(containerLeft);

        const avatar = new VRender.Image({
          width: 50,
          height: 50,
          image: taskRecord.avatar,
          cornerRadius: 25
        });
        containerLeft.add(avatar);
        const containerCenter = new VRender.Group({
          height,
          width: width - 120,
          display: 'flex',
          flexDirection: 'column'
          // alignItems: 'left'
        });
        container.add(containerCenter);

        const developer = new VRender.Text({
          text: taskRecord.developer,
          fontSize: 16,
          fontFamily: 'sans-serif',
          fill: 'white',
          fontWeight: 'bold',
          maxLineWidth: width - 120,
          boundsPadding: [10, 0, 0, 0]
        });
        containerCenter.add(developer);

        const days = new VRender.Text({
          text: `${taskDays}天`,
          fontSize: 13,
          fontFamily: 'sans-serif',
          fill: 'white',
          boundsPadding: [10, 0, 0, 0]
        });
        containerCenter.add(days);

        if (width >= 120) {
          const containerRight = new VRender.Group({
            cornerRadius: 20,
            fill: 'white',
            height: 40,
            width: 40,
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center', // 垂直方向居中对齐
            boundsPadding: [10, 0, 0, 0]
          });
          container.add(containerRight);

          const progressText = new VRender.Text({
            text: `${progress}%`,
            fontSize: 12,
            fontFamily: 'sans-serif',
            fill: 'black',
            alignSelf: 'center',
            fontWeight: 'bold',
            maxLineWidth: (width - 60) / 2,
            boundsPadding: [0, 0, 0, 0]
          });
          containerRight.add(progressText);
        }
        return {
          rootContainer: container
          // renderDefaultBar: true
          // renderDefaultText: true
        };
      },
      hoverBarStyle: {
        cornerRadius: 30
      }
    },
Enter fullscreen mode Exit fullscreen mode

Custom Rendering of Date Headers

Through the timelineHeader.scales.customLayout configuration item, the rendering method of the date header can be customized. For example:

timelineHeader: {
      backgroundColor: '#f0f0fb',
      colWidth: 80,
      scales: [
        {
          unit: 'day',
          step: 1,
          format(date) {
            return date.dateIndex.toString();
          },
          customLayout: args => {
            const colorLength = barColors.length;
            const { width, height, index, startDate, endDate, days, dateIndex, title, ganttInstance } = args;
            const container = new VRender.Group({
              width,
              height,
              fill: '#f0f0fb',
              display: 'flex',
              flexDirection: 'row',
              flexWrap: 'nowrap'
            });
            const containerLeft = new VRender.Group({
              height,
              width: 30,
              display: 'flex',
              flexDirection: 'column',
              alignItems: 'center',
              justifyContent: 'space-around'
              // fill: 'red'
            });
            container.add(containerLeft);

            const avatar = new VRender.Image({
              width: 20,
              height: 30,
              image:
                '<svg t="1724675965803" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4299" width="200" height="200"><path d="M53.085678 141.319468C23.790257 141.319468 0 165.035326 0 194.34775L0 918.084273C0 947.295126 23.796789 971.112572 53.085678 971.112572L970.914322 971.112572C1000.209743 971.112572 1024 947.396696 1024 918.084273L1024 194.34775C1024 165.136896 1000.203211 141.319468 970.914322 141.319468L776.827586 141.319468 812.137931 176.629813 812.137931 88.275862C812.137931 68.774506 796.328942 52.965517 776.827586 52.965517 757.32623 52.965517 741.517241 68.774506 741.517241 88.275862L741.517241 176.629813 741.517241 211.940158 776.827586 211.940158 970.914322 211.940158C961.186763 211.940158 953.37931 204.125926 953.37931 194.34775L953.37931 918.084273C953.37931 908.344373 961.25643 900.491882 970.914322 900.491882L53.085678 900.491882C62.813237 900.491882 70.62069 908.306097 70.62069 918.084273L70.62069 194.34775C70.62069 204.087649 62.74357 211.940158 53.085678 211.940158L247.172414 211.940158C266.67377 211.940158 282.482759 196.131169 282.482759 176.629813 282.482759 157.128439 266.67377 141.319468 247.172414 141.319468L53.085678 141.319468ZM211.862069 176.629813C211.862069 196.131169 227.671058 211.940158 247.172414 211.940158 266.67377 211.940158 282.482759 196.131169 282.482759 176.629813L282.482759 88.275862C282.482759 68.774506 266.67377 52.965517 247.172414 52.965517 227.671058 52.965517 211.862069 68.774506 211.862069 88.275862L211.862069 176.629813ZM1024 353.181537 1024 317.871192 988.689655 317.871192 35.310345 317.871192 0 317.871192 0 353.181537 0 441.457399C0 460.958755 15.808989 476.767744 35.310345 476.767744 54.811701 476.767744 70.62069 460.958755 70.62069 441.457399L70.62069 353.181537 35.310345 388.491882 988.689655 388.491882 953.37931 353.181537 953.37931 441.457399C953.37931 460.958755 969.188299 476.767744 988.689655 476.767744 1008.191011 476.767744 1024 460.958755 1024 441.457399L1024 353.181537ZM776.937913 582.62069C796.439287 582.62069 812.248258 566.811701 812.248258 547.310345 812.248258 527.808989 796.439287 512 776.937913 512L247.172414 512C227.671058 512 211.862069 527.808989 211.862069 547.310345 211.862069 566.811701 227.671058 582.62069 247.172414 582.62069L776.937913 582.62069ZM247.172414 688.551724C227.671058 688.551724 211.862069 704.360713 211.862069 723.862069 211.862069 743.363425 227.671058 759.172414 247.172414 759.172414L600.386189 759.172414C619.887563 759.172414 635.696534 743.363425 635.696534 723.862069 635.696534 704.360713 619.887563 688.551724 600.386189 688.551724L247.172414 688.551724ZM776.827586 211.940158 741.517241 176.629813 741.517241 247.328574C741.517241 266.829948 757.32623 282.638919 776.827586 282.638919 796.328942 282.638919 812.137931 266.829948 812.137931 247.328574L812.137931 176.629813 812.137931 141.319468 776.827586 141.319468 247.172414 141.319468C227.671058 141.319468 211.862069 157.128439 211.862069 176.629813 211.862069 196.131169 227.671058 211.940158 247.172414 211.940158L776.827586 211.940158ZM282.482759 176.629813C282.482759 157.128439 266.67377 141.319468 247.172414 141.319468 227.671058 141.319468 211.862069 157.128439 211.862069 176.629813L211.862069 247.328574C211.862069 266.829948 227.671058 282.638919 247.172414 282.638919 266.67377 282.638919 282.482759 266.829948 282.482759 247.328574L282.482759 176.629813Z" fill="#389BFF" p-id="4300"></path></svg>'
            });
            containerLeft.add(avatar);

            const containerCenter = new VRender.Group({
              height,
              width: width - 30,
              display: 'flex',
              flexDirection: 'column'
              // alignItems: 'left'
            });
            container.add(containerCenter);
            const dayNumber = new VRender.Text({
              text: String(dateIndex).padStart(2, '0'),
              fontSize: 20,
              fontWeight: 'bold',
              fontFamily: 'sans-serif',
              fill: 'black',
              textAlign: 'right',
              maxLineWidth: width - 30,
              boundsPadding: [15, 0, 0, 0]
            });
            containerCenter.add(dayNumber);

            const weekDay = new VRender.Text({
              text: VTableGantt.tools.getWeekday(startDate, 'short').toLocaleUpperCase(),
              fontSize: 12,
              fontFamily: 'sans-serif',
              fill: 'black',
              boundsPadding: [0, 0, 0, 0]
            });
            containerCenter.add(weekDay);
            return {
              rootContainer: container
            };
          }
        }
      ]
    },

Enter fullscreen mode Exit fullscreen mode

Custom rendering of the left task information table

Custom rendering of each column cell can be defined through taskListTable.columns.customLayout, or custom rendering of each cell can be globally defined through taskListTable.customLayout.

Support for different date scale granularities

In common business scenarios, it may be necessary to involve the display of multiple layers of time scales. VTable-Gantt supports five time granularities: 'day' | 'week' |'month' | 'quarter' | 'year'.
Through the timelineHeader.scales.unit configuration item, the row height and time unit (such as day, week, month, etc.) of the date scale can be set.
At the same time, different header styles can be configured for different time granularities:
Through the timelineHeader.scales.style configuration item, the style of the date header can be customized.
Through the timelineHeader.scales.rowHeight configuration item, the row height of the date scale can be set.


 timelineHeader: {
    colWidth: 100,
    backgroundColor: '#EEF1F5',
   .....
    scales: [
      {
        unit: 'week',
        step: 1,
        startOfWeek: 'sunday',
        format(date) {
          return `Week ${date.dateIndex}`;
        },
        style: {
          fontSize: 20,
          fontWeight: 'bold',
          color: 'white',
          strokeColor: 'black',
          textAlign: 'right',
          textBaseline: 'bottom',
          backgroundColor: '#EEF1F5',
          textStick: true
          // padding: [0, 30, 0, 20]
        }
      },
      {
        unit: 'day',
        step: 1,
        format(date) {
          return date.dateIndex.toString();
        },
        style: {
          fontSize: 20,
          fontWeight: 'bold',
          color: 'white',
          strokeColor: 'black',
          textAlign: 'right',
          textBaseline: 'bottom',
          backgroundColor: '#EEF1F5'
        }
      }
    ]
  },
Enter fullscreen mode Exit fullscreen mode

The effect is as follows:

Image description

Outer border

The border of the table may be different in style from the internal grid lines. You can customize the outer border of the Gantt chart through the frame.outerFrameStyle configuration item.

const option = {
  overscrollBehavior: 'none',
  records,
  taskListTable: {
  },
  frame: {
    outerFrameStyle: {
      borderLineWidth: 20,
      borderColor: 'black',
      cornerRadius: 8
    },
  },
Enter fullscreen mode Exit fullscreen mode

The effect is as follows:

Image description

Horizontal and Vertical Split Lines

It supports both horizontal split lines for the table header and body, as well as the split line between the left information table and the right task list. Through the frame.horizontalSplitLine configuration item, you can customize the style of the horizontal split line. The frame.verticalSplitLine configuration item allows you to customize the style of the vertical split line.

Image description

Marking Lines

In a Gantt chart, it is often necessary to mark some important dates. We configure this effect through the configuration item markLine. The key date is specified through markLine.date, and the style of the marking line can be customized through the markLine.style configuration item. If it is necessary to display this date at initialization, markLine.scrollToMarkLine can be set to true.
For example:

markLine: [
    {
      date: '2024-07-28',
      style: {
        lineWidth: 1,
        lineColor: 'blue',
        lineDash: [8, 4]
      }
    },
    {
      date: '2024-08-17',
      style: {
        lineWidth: 2,
        lineColor: 'red',
        lineDash: [8, 4]
      }
    }
  ],
Enter fullscreen mode Exit fullscreen mode

The effects are as follows:

Image description

Container Grid Lines

Through the grid configuration item, you can customize the style of the background grid lines of the task bar on the right. Including background color, line width in the horizontal and vertical directions, line type, etc.
For example:

grid: {
    verticalLine: {
      lineWidth: 3,
      lineColor: 'black'
    },
    horizontalLine: {
      lineWidth: 2,
      lineColor: 'red'
    }
  },
Enter fullscreen mode Exit fullscreen mode

The effects are as follows:

Image description

Interaction

Task Bar

The taskBar.moveable configuration item can be used to set whether the task bar is draggable.
The taskBar.resizable configuration item can be used to set whether the task bar is resizable.
The official website example of the corresponding effect: https://visactor.io/vtable/demo/gantt/gantt-interaction-drag-taskBar
For example:

taskBar: {
    startDateField: 'start',
    endDateField: 'end',
    progressField: 'progress',
    // resizable: false,
    moveable: true,
    hoverBarStyle: {
      barOverlayColor: 'rgba(99, 144, 0, 0.4)'
    },
  },
Enter fullscreen mode Exit fullscreen mode

The effects are as follows:

Image description

Adjust the width of the left table

By setting frame.verticalSplitLineMoveable to true, the split line can be made draggable.
For example:

frame: {
    outerFrameStyle: {
      borderLineWidth: 1,
      borderColor: '#e1e4e8',
      cornerRadius: 0
    },
    verticalSplitLine: {
      lineColor: '#e1e4e8',
      lineWidth: 1
    },
    horizontalSplitLine: {
      lineColor: '#e1e4e8',
      lineWidth: 1
    },
    verticalSplitLineMoveable: true,
    verticalSplitLineHighlight: {
      lineColor: 'green',
      lineWidth: 1
    }
  },
Enter fullscreen mode Exit fullscreen mode

The effects are as follows:

Image description

Full example: https://visactor.io/vtable/demo/gantt/gantt-interaction-drag-table-width

Editing task information

Through the editing capabilities of ListTable, data can be synchronized and updated to the task bar.
First, ensure that the VTable library @visactor/vtable and related editor packages @visactor/vtable-editors have been correctly installed. You can use the following commands to install them:

npm install @visactor/vtable-editors
yarn add @visactor/vtable-editors
Enter fullscreen mode Exit fullscreen mode

Import the required type of editor module in the code:

import { DateInputEditor, InputEditor, ListEditor, TextAreaEditor } from '@visactor/vtable-editors';

Enter fullscreen mode Exit fullscreen mode

You can also obtain the built VTable-Editor files through CDN.

<script src="https://unpkg.com/@visactor/vtable-editors@latest/dist/vtable-editors.min.js"></script>
<script>
  const inputEditor = new VTable.editors.InputEditor();
</script>
Enter fullscreen mode Exit fullscreen mode

Currently, the VTable-ediotrs library provides four types of editors, including text input boxes, multi-line text input boxes, date pickers, drop-down lists, etc. You can choose the appropriate editor according to your needs. (The effect of the drop-down list editor is still being optimized, and it is relatively ugly at present.)
The following is an example code for creating an editor:

const inputEditor = new InputEditor();
const textAreaEditor = new TextAreaEditor();
const dateInputEditor = new DateInputEditor();
const listEditor = new ListEditor({ values: ['女', '男'] });
Enter fullscreen mode Exit fullscreen mode

In the above example, we created a text input box editor (InputEditor), a multi-line text box editor (TextAreaEditor), a date picker editor (DateInputEditor), and a drop-down list editor (ListEditor). You can choose the appropriate editor type according to your actual needs.
Before using the editor, the editor instance needs to be registered to the VTable:

// import * as VTable from '@visactor/vtable';
// Register the editor to VTable
VTable.register.editor('name-editor', inputEditor);
VTable.register.editor('name-editor2', inputEditor2);
VTable.register.editor('textArea-editor', textAreaEditor);
VTable.register.editor('number-editor', numberEditor);
VTable.register.editor('date-editor', dateInputEditor);
VTable.register.editor('list-editor', listEditor);
Enter fullscreen mode Exit fullscreen mode

Next, the editor to be used needs to be specified in the columns configuration:

columns: [
  { title: 'name', field: 'name', editor(args)=>{
    if(args.row%2 === 0)
      return 'name-editor';
    else
      return 'name-editor2';
  } },
  { title: 'age', field: 'age', editor: 'number-editor' },
  { title: 'gender', field: 'gender', editor: 'list-editor' },
  { title: 'address', field: 'address', editor: 'textArea-editor' },
  { title: 'birthday', field: 'birthDate', editor: 'date-editor' },
]
Enter fullscreen mode Exit fullscreen mode

In the table of the left task list, users can start editing by double-clicking (or other interaction methods) on the cell.
Example of the corresponding effect on the official website: https://visactor.io/vtable/demo/gantt/gantt-edit

Image description

More comprehensive editing capabilities can be referred to the editing tutorial of VTable: https://visactor.io/vtable/guide/edit/edit_cell

Adjusting the data order

To enable the drag-and-drop reordering capability, the configuration of the ListTable requires adding rowSeriesNumber in the configuration. With row serial numbers, the style of this column can be configured. To enable reordering, set dragOrder to true. When VTable-Gantt listens to the shift event, it synchronizes the order to the task bar area for display.
For example:

rowSeriesNumber: {
    title: '行号',
    dragOrder: true,
    headerStyle: {
      fontWeight: 'bold',
      color: '#134e35',
      bgColor: '#a7c2ff'
    },
    style: {
      borderColor: '#e1e4e8',
      borderColor: '#9fb9c3',
      borderLineWidth: [1, 0, 1, 0],
    }
  },
Enter fullscreen mode Exit fullscreen mode

The effects are as follows:

Image description

Summary

This article details the existing functions of the @visactor/vtable-gantt component. Its basic and extended capabilities can already meet the requirements of the Gantt chart in most current scenarios. The component is still constantly being improved. If you have any suggestions or usage issues, please feel free to communicate.
Welcome to communicate
Finally, we sincerely welcome all friends interested in data visualization to participate in the open-source construction of VisActor:
VChart: VChart Official Website, VChart Github (Thanks for Star)
VTable: VTable Official Website, VTable Github (Thanks for Star)
VMind: VMind Official Website, VMind Github (Thanks for Star)
Official website: www.visactor.io/
Discord: discord.gg/3wPyxVyH6m
Feishu Group (External Network): Open the link and scan the code
Twiter:twitter.com/xuanhun1
github:github.com/VisActor

Top comments (0)