需求背景
由于 PMS 项目需要开发如下图所示的甘特图,需要选择合适的组件来实现此功能
技术选型
选型 | 优点 | 缺点 |
---|---|---|
gantt-schedule-timeline-calendar | 功能丰富 | 与 UI 相差较大,定制化代码较多 |
react-timeline-gantt | 功能丰富 | 与 UI 相差较大,定制化代码较多 |
gantt | 与 UI 相差不大 | 在 react 使用需要封装;缺失左侧文字描述功能 |
gantt-for-react | 与 UI 相差不大,可直接在 react 中使用 | 缺失左侧文字描述功能 |
gantt-task-react | 与 UI 最为接近 | 左侧文字展示功能较弱,只能展示固定的几个字段;关键样式没有暴露,不好做定制化 |
技术难点
需要针对 gantt-task-react 的源码做功能的增强:
- 左侧文字展示功能增强
- 样式属性暴露
核心逻辑
所有代码在这里,这里只展示核心逻辑
左侧文字展示功能
左侧文字展示的功能和 antd 的列表渲染 API 一致
表头渲染
主要功能如下:
- 表头的宽度
- 标题渲染
src/components/task-list/task-list-header.tsx
import React from "react";
+ import { ListColumn } from "../../types/public-types";
import styles from "./task-list-header.module.css";
export const TaskListHeaderDefault: React.FC<{
headerHeight: number;
rowWidth: string;
fontFamily: string;
fontSize: string;
- }> = ({ headerHeight, fontFamily, fontSize, rowWidth }) => {
+ listColumns: ListColumn[];
+ }> = ({ headerHeight, fontFamily, fontSize, listColumns, rowWidth }) => {
return (
<div
className={styles.ganttTable}
style={{
fontFamily: fontFamily,
fontSize: fontSize,
+ width: rowWidth,
}}
>
<div
@@ -21,44 +24,17 @@ export const TaskListHeaderDefault: React.FC<{
height: headerHeight - 2,
}}
>
- <div
- className={styles.ganttTable_HeaderItem}
- style={{
- minWidth: rowWidth,
- }}
- >
- Name
- </div>
- <div
- className={styles.ganttTable_HeaderSeparator}
- style={{
- height: headerHeight * 0.5,
- marginTop: headerHeight * 0.2,
- }}
- />
- <div
- className={styles.ganttTable_HeaderItem}
- style={{
- minWidth: rowWidth,
- }}
- >
- From
- </div>
- <div
- className={styles.ganttTable_HeaderSeparator}
- style={{
- height: headerHeight * 0.5,
- marginTop: headerHeight * 0.25,
- }}
- />
- <div
- className={styles.ganttTable_HeaderItem}
- style={{
- minWidth: rowWidth,
- }}
- >
- To
- </div>
+ {listColumns.map(item => (
+ <div
+ key={item.key || item.dataIndex}
+ className={styles.ganttTable_HeaderItem}
+ style={{
+ width: item.width || "auto",
+ }}
+ >
+ {item.title}
+ </div>
+ ))}
</div>
</div>
);
列表内容渲染
主要功能如下:
- 表格宽度
- 列表内容渲染
- 表格行、列单元格合并
- 表格长标题过长时省略
src/components/task-list/task-list-table.tsx
- import React, { useMemo } from "react";
+ import React from "react";
+ import classnames from "classnames";
+
import styles from "./task-list-table.module.css";
- import { Task } from "../../types/public-types";
+ import { Task, ListColumn } from "../../types/public-types";
+ import { getPathValue } from "../../helpers/other-helper";
- const localeDateStringCache = {};
- const toLocaleDateStringFactory = (locale: string) => (
- date: Date,
- dateTimeOptions: Intl.DateTimeFormatOptions
- ) => {
- const key = date.toString();
- let lds = localeDateStringCache[key];
- if (!lds) {
- lds = date.toLocaleDateString(locale, dateTimeOptions);
- localeDateStringCache[key] = lds;
- }
- return lds;
- };
- const dateTimeOptions: Intl.DateTimeFormatOptions = {
- weekday: "short",
- year: "numeric",
- month: "long",
- day: "numeric",
- };
+ function isRenderCell(data: any) {
+ return (
+ data &&
+ typeof data === "object" &&
+ !Array.isArray(data) &&
+ !React.isValidElement(data)
+ );
+ }
export const TaskListTableDefault: React.FC<{
rowHeight: number;
@@ -32,84 +44,153 @@ export const TaskListTableDefault: React.FC<{
selectedTaskId: string;
setSelectedTask: (taskId: string) => void;
onExpanderClick: (task: Task) => void;
+ listColumns: ListColumn[];
}> = ({
rowHeight,
rowWidth,
tasks,
fontFamily,
fontSize,
- locale,
onExpanderClick,
+ listColumns,
}) => {
- const toLocaleDateString = useMemo(() => toLocaleDateStringFactory(locale), [
- locale,
- ]);
return (
- <div
+ <table
className={styles.taskListWrapper}
style={{
fontFamily: fontFamily,
fontSize: fontSize,
+ width: rowWidth,
}}
>
- {tasks.map(t => {
- let expanderSymbol = "";
- if (t.hideChildren === false) {
- expanderSymbol = "▼";
- } else if (t.hideChildren === true) {
- expanderSymbol = "▶";
- }
+ <tbody>
+ {tasks.map((t, index) => {
+ let expanderSymbol = "";
+ if (t.hideChildren === false) {
+ expanderSymbol = "▼";
+ } else if (t.hideChildren === true) {
+ expanderSymbol = "▶";
+ }
- return (
- <div
- className={styles.taskListTableRow}
- style={{ height: rowHeight }}
- key={`${t.id}row`}
- >
- <div
- className={styles.taskListCell}
- style={{
- minWidth: rowWidth,
- maxWidth: rowWidth,
- }}
- title={t.name}
+ return (
+ <tr
+ className={styles.taskListTableRow}
+ style={{ height: rowHeight }}
+ key={`${t.id}row`}
>
- <div className={styles.taskListNameWrapper}>
- <div
- className={
- expanderSymbol
- ? styles.taskListExpander
- : styles.taskListEmptyExpander
+ {listColumns.map(item => {
+ let childNode: any;
+ let cellProps;
+ if (item.children) {
+ childNode = item.children;
+ } else {
+ const value = getPathValue(t, item.dataIndex);
+ childNode = value;
+ if (item.render) {
+ const renderData = item.render(value, t, index);
+ if (isRenderCell(renderData)) {
+ childNode = renderData.children;
+ cellProps = renderData.props;
+ } else {
+ childNode = renderData;
+ }
}
- onClick={() => onExpanderClick(t)}
- >
- {expanderSymbol}
- </div>
- <div>{t.name}</div>
- </div>
- </div>
- <div
- className={styles.taskListCell}
- style={{
- minWidth: rowWidth,
- maxWidth: rowWidth,
- }}
- >
- {toLocaleDateString(t.start, dateTimeOptions)}
- </div>
- <div
- className={styles.taskListCell}
- style={{
- minWidth: rowWidth,
- maxWidth: rowWidth,
- }}
- >
- {toLocaleDateString(t.end, dateTimeOptions)}
- </div>
- </div>
- );
- })}
- </div>
+ }
+ if (
+ typeof childNode === "object" &&
+ !Array.isArray(childNode) &&
+ !React.isValidElement(childNode)
+ ) {
+ childNode = null;
+ }
+ const { colSpan: cellColSpan, rowSpan: cellRowSpan } =
+ cellProps || {};
+ const mergedColSpan =
+ cellColSpan !== undefined ? cellColSpan : item.colSpan;
+ const mergedRowSpan =
+ cellRowSpan !== undefined ? cellRowSpan : item.rowSpan;
+ if (mergedColSpan === 0 || mergedRowSpan === 0) {
+ return null;
+ }
+ let title;
+ const ellipsisConfig =
+ item.ellipsis === true
+ ? {
+ showTitle: true,
+ }
+ : item.ellipsis;
+ if (ellipsisConfig && ellipsisConfig.showTitle) {
+ if (
+ typeof childNode === "string" ||
+ typeof childNode === "number"
+ ) {
+ title = childNode.toString();
+ } else if (
+ React.isValidElement(childNode) &&
+ // @ts-ignore
+ typeof childNode.props.children === "string"
+ ) {
+ // @ts-ignore
+ title = childNode.props.children;
+ }
+ }
+ return (
+ <td
+ key={item.key || item.dataIndex}
+ className={classnames(
+ styles.taskListCell,
+ item.ellipsis ? styles.ellipsis : ""
+ )}
+ style={{
+ width: item.width || "auto",
+ }}
+ colSpan={
+ mergedColSpan && mergedColSpan !== 1
+ ? mergedColSpan
+ : null
+ }
+ rowSpan={
+ mergedRowSpan && mergedRowSpan !== 1
+ ? mergedRowSpan
+ : null
+ }
+ >
+ <div className={styles.taskListNameWrapper}>
+ <div
+ className={
+ expanderSymbol
+ ? styles.taskListExpander
+ : styles.taskListEmptyExpander
+ }
+ onClick={() => onExpanderClick(t)}
+ >
+ {expanderSymbol}
+ </div>
+ <div title={title} className={styles.ellipsis}>
+ {childNode}
+ </div>
+ </div>
+ </td>
+ );
+ })}
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
);
};
样式属性暴露
主要功能如下:
- 行级斑马线显示
- 日期分隔线显示
- 行级分隔线显示
src/components/grid/grid-body.tsx
columnWidth: number;
todayColor: string;
rtl: boolean;
+ hasCrosswalk: boolean;
+ hasDateLine: boolean;
+ renderRowLines: (index: number) => boolean;
};
+
export const GridBody: React.FC<GridBodyProps> = ({
tasks,
dates,
@@ -20,6 +24,9 @@ export const GridBody: React.FC<GridBodyProps> = ({
columnWidth,
todayColor,
rtl,
+ hasCrosswalk,
+ hasDateLine,
+ renderRowLines,
}) => {
let y = 0;
const gridRows: ReactChild[] = [];
@@ -33,27 +40,33 @@ export const GridBody: React.FC<GridBodyProps> = ({
className={styles.gridRowLine}
/>,
];
- for (const task of tasks) {
- gridRows.push(
- <rect
- key={"Row" + task.id}
- x="0"
- y={y}
- width={svgWidth}
- height={rowHeight}
- className={styles.gridRow}
- />
- );
- rowLines.push(
- <line
- key={"RowLine" + task.id}
- x="0"
- y1={y + rowHeight}
- x2={svgWidth}
- y2={y + rowHeight}
- className={styles.gridRowLine}
- />
- );
+ for (let i = 0; i < tasks.length; i++) {
+ const task = tasks[i];
+ if (hasCrosswalk) {
+ gridRows.push(
+ <rect
+ key={"Row" + task.id}
+ x="0"
+ y={y}
+ width={svgWidth}
+ height={rowHeight}
+ className={styles.gridRow}
+ />
+ );
+ }
+ const isRenderRowLines = renderRowLines(i);
+ if (isRenderRowLines) {
+ rowLines.push(
+ <line
+ key={"RowLine" + task.id}
+ x="0"
+ y1={y + rowHeight}
+ x2={svgWidth}
+ y2={y + rowHeight}
+ className={styles.gridRowLine}
+ />
+ );
+ }
y += rowHeight;
}
@@ -63,16 +76,18 @@ export const GridBody: React.FC<GridBodyProps> = ({
let today: ReactChild = <rect />;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
- ticks.push(
- <line
- key={date.getTime()}
- x1={tickX}
- y1={0}
- x2={tickX}
- y2={y}
- className={styles.gridTick}
- />
- );
+ if (hasDateLine) {
+ ticks.push(
+ <line
+ key={date.getTime()}
+ x1={tickX}
+ y1={0}
+ x2={tickX}
+ y2={y}
+ className={styles.gridTick}
+ />
+ );
+ }
if (
(i + 1 !== dates.length &&
date.getTime() < now.getTime() &&
实现效果
总结
- ts 写的不够熟练且精简,需要加强
- 对于样式属性暴露应该用固定类名去控制,原作者包含很多的 className 属性,非常冗余
- 本地源码优化很少涉及到 svg 的优化,需要加强 svg 的学习