头图

Excuse me? 产品让我实现一个值班表

产品经理让我实现一个值班表,然而我找遍了开源项目,都没有找到符合设计的交互需求,没有办法,我只好自己实现了一个值班表。如下图所示:

ps: 值班表的实现采用 react.js 技术栈,所以也将以 react.js 为主来讲解。

实现模块分析

根据交互图,我将整个值班表拆分出了如下模块:

  • 标题
  • 用户搜索框
  • 日期选择
  • 按钮组

    • 恢复按钮
    • 发布按钮
  • 值班表
  • 用户展示信息
  • 值班表头。
  • 值班表体,分为行和列,每一列具体可以看作是一个块。

    • 块。
    • 徽标
    • 新增块。
    • 编辑块。
    • 删除块。

首先我们来分析一下每一个模块要实现的具体功能有哪些。

首先是标题部分,用户搜索框,输入一个用户名或者是用户的邮箱号,然后返回用户,然后选择用户就可以只显示当前用户的值班表。其次是切换日期部分,切换日期之后,值班表将变成以选中日期为主,后 14 天范围的值班表。由于这里的值班表数据是模拟的,以及这里需要调用接口,由后端提供 14 天范围的数据,因此这个功能实际上是由后端来完成的,不过前端也完成了相应的调接口传参数的逻辑。

ps: 由于这里涉及到了缓存数据,因此几乎每个操作,都添加了二次确认的提示。

接下来是恢复按钮,恢复的逻辑也比较简单,就是添加一个二次确认,然后如果点击了确定,我们实际上就是返回调用接口的原始数据即可,然后就是发布按钮,发布按钮的逻辑也不复杂,同样是给一个二次确认,然后再将最新的值班表的数据当作参数传给后端,由后端来修改并返回。

ps: 本次只展示了前端的实现,因此数据都是在前端模拟的,因此发布按钮只是相当于做了一个简单的逻辑。

标题组件的交互功能逻辑算是完了,接下来我们来看值班表,首先是值班表头,值班表头展示 14 天的日期数据以及当前是星期几。其次是值班表体,表体第一列展示的是用户信息组件,因此这里也需要单独实现一个用户信息组件。

注意: 实际业务场景中已经有封装好了的用户信息组件,这里只是做的一个模拟实现。

然后其它列就是展示的值班信息,值班信息每一列我把它叫做块,对于块,首先是展示值班信息,如果没有值班信息,就是一个空块,如果有值班信息,那就要以对应的颜色区分(字体色,背景色以及边框色)。然后就是对块的增删改查了,如果有值班信息,那么就是编辑,没有就出现显示新增的块,悬浮上去即出现,然后单击就是新增块,新增块和编辑块都是出现一个气泡确认提示框,气泡确认提示框里的内容是三个表单项,包含确认取消按钮以及关闭按钮点击关闭气泡确认提示框的逻辑,里面的表单项分成三个,即选择值班名称,值班时间段,以及对应的值班人。只有值班名称需要由用户选择,用户选择了之后,根据值班名称来展示出对应的值班时间段,值班时间段是不需要用户修改的,然后值班人也是不需要用户修改的,只是做一个展示用。编辑值班信息与新增值班信息相比只是多了一个表单数据回填,另外只有不是空块才会是单击块变成编辑值班信息,否则就是新增值班信息。最后就是删除值班信息,块的右上角出现一个删除图标,如果块含有值班信息,才可以删除。

这里也做了不少交互优化,首先就是单击块可以编辑信息会出现 tooltip 提示信息,然后是删除也会有,并且单击也会有二次确认提示,而且删除图标是鼠标悬浮块之后出现。然后就是如果用户做了增删改的操作,都会展示一个徽标组件,标注用户修改过,这是因为这里会有数据缓存,用户如果做了改动,要给予用户一些反馈,并且刷新或者是关闭浏览器的时候要有所反馈。

以上是对所有的交互做了一个具体的分析,接下来我们来看本次实现所需要的技术栈。

所用到的技术栈

本次值班表的实现用到的技术栈也有不少,分别列出如下:

  1. react + vite + typescript + less + css
  2. antd (原版使用的是 acro design),不过这里也引入了 acro design,因为要用到 acro design 提供的 Trigger 组件。
  3. lodash
  4. day.js
  5. ahooks
  6. valtio
  7. query-string
  8. axios

axios 和 query-string 在模拟请求当中会用到,其中 query-string 用在序列化参数,antd 用到的都是一些已知会用到的组件,acro desing 只用到了一个 Trigger 组件,lodash 主要是用到一些工具函数,这里有日期处理,所以也需要用到 day.js,然后有一些数据状态管理或者是请求也用到了 ahooks,这里还用到一个 valtio 技术栈,它是 react 当中的一个响应式数据库,我们这里是对值班表信息做修改,如果每次增删改查都需要调用 setState 去重置整个数据,那是很繁琐的操作,因此采用的这个库来做数据状态管理。

数据结构分析

目前前端设计的数据结构如下所示:

type DutyDataMap = {
  date: string;
  calendarList: {
    username: string;
    shiftList: {
      id: number;
      name: string;
      time: {
        start: number;
        end: number;
      };
    }[];
  }[];
};

而实际上后端设计的数据结构是这样的:

type DutySchedules = {
  date: string;
  shiftList: {
    name: string;
    time: {
      end: number;
      start: number;
    };
    username: string;
  }[];
}[];

可以看到这是有差异,没有跟后端对齐数据结构,这个中原因就不便说了,我们暂且以当前数据结构为主,更何况涉及到了增删改,也需要添加一些标志性的字段,这里只是相当于给前端增加了数据转换的操作,但最终点击发布的时候,我们还是需要对数据进行转换,因此增加了在前端增加数据转换的工作量。

然后我们创建了 2 个 json 文件,一个是用户相关数据,一个是值班表数据,如下所示:

用户信息:

[
  { "email": "123456789@qq.com", "username": "test-1" },
  { "email": "789@qq.com", "username": "test-2" },
  { "email": "123456@qq.com", "username": "test-3" },
  { "email": "456@qq.com", "username": "test-4" },
  { "email": "12345678@qq.com", "username": "test-5" },
  { "email": "123@qq.com", "username": "test-6" }
]

值班信息:

[
  {
    "date": "2024-06-21",
    "shiftList": [
      {
        "name": "中班",
        "time": {
          "end": 1718982000,
          "start": 1718949600
        },
        "username": "123456789@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1718982000,
          "start": 1718949600
        },
        "username": "789@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719014400,
          "start": 1718982000
        },
        "username": "123456@qq.com"
      },
      {
        "name": "早班",
        "time": {
          "end": 1718964000,
          "start": 1718928000
        },
        "username": "456@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-22",
    "shiftList": [
      {
        "name": "早班",
        "time": {
          "end": 1719050400,
          "start": 1719014400
        },
        "username": "456@qq.com"
      },
      {
        "name": "早班",
        "time": {
          "end": 1719050400,
          "start": 1719014400
        },
        "username": "123@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719068400,
          "start": 1719036000
        },
        "username": "123456789@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719068400,
          "start": 1719036000
        },
        "username": "789@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719100800,
          "start": 1719068400
        },
        "username": "123456@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-23",
    "shiftList": [
      {
        "name": "晚班",
        "time": {
          "end": 1719187200,
          "start": 1719154800
        },
        "username": "123456@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719154800,
          "start": 1719122400
        },
        "username": "123@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719154800,
          "start": 1719122400
        },
        "username": "789@qq.com"
      },
      {
        "name": "早班",
        "time": {
          "end": 1719136800,
          "start": 1719100800
        },
        "username": "456@qq.com"
      },
      {
        "name": "早班",
        "time": {
          "end": 1719136800,
          "start": 1719100800
        },
        "username": "12345678@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-24",
    "shiftList": [
      {
        "name": "早班",
        "time": {
          "end": 1719223200,
          "start": 1719187200
        },
        "username": "12345678@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719241200,
          "start": 1719208800
        },
        "username": "123@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719241200,
          "start": 1719208800
        },
        "username": "789@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719273600,
          "start": 1719241200
        },
        "username": "123456@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-25",
    "shiftList": [
      {
        "name": "早班",
        "time": {
          "end": 1719309600,
          "start": 1719273600
        },
        "username": "12345678@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719327600,
          "start": 1719295200
        },
        "username": "123@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719327600,
          "start": 1719295200
        },
        "username": "789@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719360000,
          "start": 1719327600
        },
        "username": "123456789@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-26",
    "shiftList": [
      {
        "name": "早班",
        "time": {
          "end": 1719396000,
          "start": 1719360000
        },
        "username": "12345678@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719446400,
          "start": 1719414000
        },
        "username": "123456789@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719414000,
          "start": 1719381600
        },
        "username": "123@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719414000,
          "start": 1719381600
        },
        "username": "456@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-27",
    "shiftList": [
      {
        "name": "早班",
        "time": {
          "end": 1719482400,
          "start": 1719446400
        },
        "username": "12345678@qq.com"
      },
      {
        "name": "早班",
        "time": {
          "end": 1719482400,
          "start": 1719446400
        },
        "username": "789@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719500400,
          "start": 1719468000
        },
        "username": "123@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719500400,
          "start": 1719468000
        },
        "username": "456@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719532800,
          "start": 1719500400
        },
        "username": "123456789@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-28",
    "shiftList": [
      {
        "name": "早班",
        "time": {
          "end": 1719568800,
          "start": 1719532800
        },
        "username": "12345678@qq.com"
      },
      {
        "name": "早班",
        "time": {
          "end": 1719568800,
          "start": 1719532800
        },
        "username": "123456@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719586800,
          "start": 1719554400
        },
        "username": "789@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719586800,
          "start": 1719554400
        },
        "username": "456@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719619200,
          "start": 1719586800
        },
        "username": "123456789@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-29",
    "shiftList": [
      {
        "name": "早班",
        "time": {
          "end": 1719655200,
          "start": 1719619200
        },
        "username": "123456@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719673200,
          "start": 1719640800
        },
        "username": "789@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719673200,
          "start": 1719640800
        },
        "username": "456@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719705600,
          "start": 1719673200
        },
        "username": "123456789@qq.com"
      }
    ]
  },
  {
    "date": "2024-06-30",
    "shiftList": [
      {
        "name": "早班",
        "time": {
          "end": 1719741600,
          "start": 1719705600
        },
        "username": "123456@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719759600,
          "start": 1719727200
        },
        "username": "789@qq.com"
      },
      {
        "name": "中班",
        "time": {
          "end": 1719759600,
          "start": 1719727200
        },
        "username": "456@qq.com"
      },
      {
        "name": "晚班",
        "time": {
          "end": 1719792000,
          "start": 1719759600
        },
        "username": "123@qq.com"
      }
    ]
  },
  {
    "date": "2024-07-01",
    "shiftList": []
  },
  {
    "date": "2024-07-02",
    "shiftList": []
  },
  {
    "date": "2024-07-03",
    "shiftList": []
  },
  {
    "date": "2024-07-04",
    "shiftList": []
  }
]

初始化项目

参考vite,我们使用如下命令来初始化一个工程项目。

pnpm create vite duty-roster --template react-ts
注意: 这里使用的是 pnpm 包管理工具,读者可自行使用其它包管理工具初始化项目。

初始化项目完成之后,我们需要改造一下整个项目目录结构,如下所示:

当然具体目录结构读者也可以自行分类管理,方便维护组织代码即可,app.css 我们不需要,因此删掉,index.css 改造如下所示:

/* * 选择器不是一个很好的选择,不过原项目已经做了这种初始化,这里也照搬过来就是了 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
/** 整体布局元素加个padding内间距 */
.container {
  padding: 20px;
}

然后就是 app.tsx 稍微改造一下,如下所示:

import DutyRoster from "./components/duty-roster";

const App = () => (
  <div className="container">
    <DutyRoster />
  </div>
);

export default App;

准备工作,一些工具函数以及数据类型定义都要做好,还有一些常量的定义

接口类型定义

首先是我们的类型定义,如下所示:

data.interface.ts:

/**
 * 前端设计的数据接口定义,稍微改造了一下
 */
export type DutyDataMap = {
  date: string;
  calendarList: {
    username: string;
    shiftList: {
      id: number;
      name: string;
      time: {
        start: number;
        end: number;
      };
    }[];
  }[];
};
export type OriginDutyShiftItem =
  DutyDataMap["calendarList"][number]["shiftList"][number];
export type OriginDutyShiftItemNoTime = Omit<OriginDutyShiftItem, "time">;
export type OriginDutyShiftItemTime = Pick<OriginDutyShiftItem, "time">["time"];
export interface DutyShiftItem extends OriginDutyShiftItemNoTime {
  visible?: boolean;
  isDelete?: boolean;
  isEdit?: boolean;
  isShowEdit?: boolean;
  isAdd?: boolean;
  time: Partial<OriginDutyShiftItemTime>;
  date?: string;
}

export interface CalendarItem {
  username: string;
  shiftList: DutyShiftItem[];
}

/** 接口用户信息返回定义 */
export interface APIUserValue {
  email: string;
  username: string;
}
/* 接口返回值班信息定义 */
export type DutySchedules = {
  date: string;
  shiftList: {
    name: string;
    time: {
      end: number;
      start: number;
    };
    username: string;
  }[];
}[];

常量定义

接下来是常量定义,包含周数据,值班数据,值班时间段,值班数据映射对象,值班数据映射转换函数,值班时间段,颜色值,日期范围,以及日期展示格式。如下所示:

/** 周数据 */
export const chineseWeekdays = [
  "星期日",
  "星期一",
  "星期二",
  "星期三",
  "星期四",
  "星期五",
  "星期六",
];
/** 值班数据 */
export const dutyList = [
  {
    label: "早班",
    value: "早班",
  },
  {
    label: "中班",
    value: "中班",
  },
  {
    label: "晚班",
    value: "晚班",
  },
];
/** 值班数据映射 */
export const word = {
  早班: "morning",
  中班: "noon",
  晚班: "evening",
};
export type WordMap = typeof word;
export type WordKey = keyof WordMap;
/** 映射转换函数 */
export const charToWord = (value: WordKey): DutyTimeColorKey =>
  word[value] as DutyTimeColorKey;
/** 值班时间段 */
export const dutyValueTime = {
  morning: "08:00:00~18:00:00",
  noon: "14:00:00~23:00:00",
  evening: "23:00:00~08:00:00",
};
/** 颜色值 */
export const dutyTimeColor = {
  morning: {
    bgColor: "#e8f4fd",
    color: "#266efe",
    borderColor: "#266efe",
  },
  noon: {
    bgColor: "#e8fffc",
    color: "#20cad4",
    borderColor: "#20cad4",
  },
  evening: {
    bgColor: "#fffae8",
    color: "#fa9f25",
    borderColor: "#fa9f25",
  },
};

export type DutyTimeColorMap = typeof dutyTimeColor;
export type DutyTimeColorKey = keyof DutyTimeColorMap;

/** 日期范围 */
export const DEFAULT_RANGE_DATE = 14;
/** 日期展示格式 */
export const DEFAULT_DATE_FORMAT = "YYYY-MM-DD HH:mm:ss";

工具函数的定义

主要就 2 个工具函数,都是基于 day.js 的 api 来进行封装的,可以封装,也可以不封装,读者自行决断。代码如下所示:

import dayjs from "dayjs";
import { DEFAULT_DATE_FORMAT, DEFAULT_RANGE_DATE } from "./const";

export const getNextFourteenDate = (
  start: string,
  format = "MM-DD",
  range = DEFAULT_RANGE_DATE
) => {
  const res: string[] = [];
  for (let i = 0; i > -range; i--) {
    res.push(dayjs(start).subtract(i, "day").format(format));
  }
  return res;
};

export const formatDateByTimeStamp = (
  time: string | number | Date,
  format = DEFAULT_DATE_FORMAT
) => dayjs(time).format(format);

顾名思义,getNextFourteenDate 表示获取 14 天日期,start 参数为起始日期,format 为返回日期格式,range 为范围天数,在这里我们调用 dayjs.subtract 来获取当天的日期,这里使用递减循环的原因是 subtract 的第一个参数是负数。

第二个工具函数就是返回一个简单的日期,可能也算是一个过度的冗余封装吧。

模拟请求

在 api/request.ts 中,我们模拟了一些请求,由于这里只有更新值班信息和查看值班信息以及请求用户列表接口,因此,我们这里只需要模拟三个请求的接口。代码如下所示:

import axios from "axios";
import queryString from "query-string";
import { DutySchedules, APIUserValue } from "../data/data.interface";
/** 定义查询参数 */
export interface QueryParams {
  date?: string;
  day?: number;
}
/** 定义更新参数 */
export interface UpdateParams {
  calendarList?: DutySchedules;
}
/**
 * 模拟查询值班表数据请求
 * @param data
 * @returns
 */
export const query = (data: QueryParams): Promise<{ data: DutySchedules }> => {
  // query-string 的用法就在这里
  const params = queryString.stringify(data);
  return axios.get("/data.json?" + params);
};
/**
 * 模拟更新值班表数据请求
 * @param data
 * @returns
 */
export const update = async (data: DutySchedules): Promise<DutySchedules> => {
  const params = queryString.stringify(data);
  const list = await axios("/data.json?" + params);
  return new Promise((resolve, reject) => {
    if (list) {
      resolve(data);
    } else {
      reject("error");
    }
  });
};
/**
 * 模拟请求用户
 * @param username
 * @returns
 */
export async function requestUserList(
  username: string
): Promise<APIUserValue[]> {
  return axios
    .get<{ username: string }, APIUserValue[]>("/username.json")
    .then((body) =>
      body?.filter(
        (item) =>
          item.email.includes(username) || item.username.includes(username)
      )
    );
}

这里定义了 2 个接口,这是根据后端想要的传参来定义的,即:

/** 定义查询参数 */
export interface QueryParams {
  date?: string; // 查询日期
  day?: number; // 查询范围天数,传14即可
}
/** 定义更新参数 */
export interface UpdateParams {
  calendarList?: DutySchedules; // 更新后的参数
}

添加块

首先我们来看添加块组件,这个组件很简单,就是一个 div 元素套一个图标元素即可。代码如下所示:

import { PlusOutlined } from "@ant-design/icons";
import React from "react";

export interface AddBlockButtonProps {
  onClick?: (...v: any[]) => void;
}
const AddBlockButton: React.FC<AddBlockButtonProps> = (props) => {
  return (
    <div className="duty-roster-excel-add-block-btn" {...props}>
      <PlusOutlined />
    </div>
  );
};

export default AddBlockButton;

稍微复杂一点就是对样式的处理,我们知道这个添加块按钮是鼠标悬浮到空块上去才会出现的,这里我们采用了 css 方案来处理这个问题。如下所示:

@prefix: duty-roster-excel;
@borderColor: rgba(229, 230, 235, 1);
.@{prefix} {
  //...
  &-cell {
    //...
    // 无名字的类名noName,就代表是空块,悬浮即展示添加块按钮
    &.noName:hover .@{prefix}-add-block-btn{
      display: flex;
      transform: scale(1);
    }
    &-content {
      // 添加块样式
      .@{prefix}-add-block-btn {
        display: none;
        align-items: center;
        justify-content: center;
        width: 80px;
        height: 80px;
        border: 2px dashed @borderColor;
        transform: scale(0);
        transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1);
      }
    }
  }
}

以上,我们为空块加了一个 noName 的类名,然后使用 hover 伪类选择器来实现悬浮才显示添加块按钮的逻辑。这里涉及到了 less 的一些基础语法,都是很好理解的。

用户输入多选

接下来我们来看用户输入多选组件。用户输入组件基于 antd 的官方 demo 做了一些改造,代码如下所示:

import React, {
  CSSProperties,
  ReactNode,
  useMemo,
  useRef,
  useState,
} from "react";
import { Select, Spin } from "antd";
import type { SelectProps } from "antd";
import debounce from "lodash/debounce";
import UserInfo from "./user-info";
import { requestUserList } from "../../api/request";

export interface DebounceSelectProps<ValueType = any>
  extends Omit<SelectProps<ValueType | ValueType[]>, "options" | "children"> {
  fetchOptions: (search: string) => Promise<ValueType[]>;
  debounceTimeout?: number;
}

function DebounceSelect<
  ValueType extends {
    key?: string;
    label: React.ReactNode;
    value: string | number;
  } = any
>({
  fetchOptions,
  debounceTimeout = 800,
  ...props
}: DebounceSelectProps<ValueType>) {
  const [fetching, setFetching] = useState(false);
  const [options, setOptions] = useState<ValueType[]>([]);
  const fetchRef = useRef(0);

  const debounceFetcher = useMemo(() => {
    const loadOptions = (value: string) => {
      fetchRef.current += 1;
      const fetchId = fetchRef.current;
      setOptions([]);
      setFetching(true);

      fetchOptions(value).then((newOptions) => {
        if (fetchId !== fetchRef.current) {
          // for fetch callback order
          return;
        }

        setOptions(newOptions);
        setFetching(false);
      });
    };

    return debounce(loadOptions, debounceTimeout);
  }, [fetchOptions, debounceTimeout]);

  return (
    <Select
      labelInValue
      filterOption={false}
      onSearch={debounceFetcher}
      notFoundContent={fetching ? <Spin size="small" /> : null}
      {...props}
      options={options}
    />
  );
}

interface UserValue {
  label: ReactNode;
  value: string;
  username?: string;
}

async function fetchUserList(username: string): Promise<UserValue[]> {
  return requestUserList(username).then((body) =>
    body?.map((item) => ({
      label: <UserInfo username={item.email} />,
      username: item.username,
      value: item.email,
    }))
  );
}

export interface SearchUserInputProps {
  style?: CSSProperties;
  onSearch?: (v: string[]) => void;
}
const SearchUserInput: React.FC<SearchUserInputProps> = (props) => {
  const { onSearch, ...rest } = props;
  const [value, setValue] = useState<UserValue[]>([]);

  return (
    <DebounceSelect
      mode="multiple"
      value={value}
      placeholder="请输入想要搜索的用户名或者邮箱号"
      fetchOptions={fetchUserList}
      onChange={(newValue) => {
        setValue(newValue as UserValue[]);
        const searchValue = (newValue as UserValue[])?.map(
          (item) => item.value
        );
        props.onSearch?.(searchValue);
      }}
      style={{ width: "100%" }}
      {...rest}
    />
  );
};

export default SearchUserInput;
注:这里遗留了一个标签展示不全的问题,读者可以自行优化。

与官方 demo 相比,只改造了一下 fetchUserList 的请求逻辑以及 UserValue 的类型参数,然后就是通过 onChange 事件抛出了 onSearch 方法出去,这里也将值做了一层过滤,然后抛出给 onSearch 作为参数。

用户信息组件

紧接着,我们使用了 Meta 组件来对用户信息组件做了一层包装,对于用户信息组件,我们只需要根据邮箱号就可以找到对应的用户信息,因此也就有了这个用户信息组件。

注: 原始业务代码使用的是内部的用户信息组件,这里之所以要封装这个组件,也算是为了还原真实的业务场景。

根据以上的分析,用户信息组件的实现就很简单了,如下所示:

import { Avatar, Card } from "antd";
import { useEffect } from "react";
import { APIUserValue } from "../../data/data.interface";
import { useSafeState } from "ahooks";
import { requestUserList } from "../../api/request";
import icon from "../../assets/react.svg";

const { Meta } = Card;
export interface UserInfoProps {
  username?: string;
}
const UserInfo: React.FC<UserInfoProps> = (props) => {
  const { username } = props;
  const [userList, setUserList] = useSafeState<APIUserValue[]>([]);
  useEffect(() => {
    if (username) {
      requestUserList(username).then((res) => {
        setUserList(res);
      });
    }
  }, [username]);
  return (
    <Meta
      avatar={<Avatar src={icon} />}
      title={userList.find((user) => user.email === username)?.username}
      description={username}
      className="user-info"
    />
  );
};

export default UserInfo;

可以看到,在用户信息组件当中,我们使用 ahooks 的 useSafeState 来存储用户信息列表,然后判断如果传入了 username,就进行过滤匹配,然后再展示即可。

值班时间段展示组件

对于值班时间段,我们要传给后端的是一个时间戳,而展示给前端看的确实字符串,因此这里就需要单独封装一个这样的组件。而值班时间段我们是不需要进行更改的,根据选择对应的班次名来匹配对应的值班时间段。因此这里我们只需要一个 input 组件即可,只不过我们需要对展示的字段进行一层过滤即可。代码如下所示:

import { Input, InputProps } from "antd";
import React, { useEffect, useMemo } from "react";
import { OriginDutyShiftItemTime } from "../../data/data.interface";
import { useSafeState } from "ahooks";
import _ from "lodash";
import { formatDateByTimeStamp } from "../util";

export interface TimeInputProps {
  value: OriginDutyShiftItemTime;
  inputProps: InputProps;
  onChange: React.ChangeEventHandler<HTMLInputElement>;
}

const TimeInput: React.FC<Partial<TimeInputProps>> = (props) => {
  const { value, onChange, inputProps } = props;
  const [timeValue, setTimeValue] = useSafeState(value);
  useEffect(() => {
    if (!_.isEqual(timeValue, value)) {
      setTimeValue(value);
    }
  }, [value]);
  const showValue = useMemo(() => {
    if (!timeValue!.start || !timeValue!.end) {
      return "";
    }
    const start = formatDateByTimeStamp(timeValue!.start * 1000);
    const end = formatDateByTimeStamp(timeValue!.end * 1000);
    return `${start.split(" ")[1]}~${end.split(" ")[1]}`;
  }, [timeValue]);
  return (
    <Input disabled {...inputProps} value={showValue} onChange={onChange} />
  );
};

export default TimeInput;

在这个组件当中,我们监听了父组件传来的 value 值, value 值应该是一个{start:0,end:0}这样的时间戳对象,然后我们用 useSafeState 来存储数据,监听这两个值是否相等从而做修改。紧接着我们使用 useMemo 来管理展示的状态,将最终的展示值传给 input 的 value 属性。

popup 弹框组件

对于每一个块,我们都有点击和编辑逻辑,当点击和编辑的时候将会出来一个弹框,这个弹框展示一些值班信息的修改表单项,别看这里只是一个小小的弹框,但是这里的逻辑也是很多的,因此我们需要单独封装一个这样的组件。

这里由于我们是对一个数组数据进行操作修改,因此我们需要类似于 vue 的 ref 那样的响应式状态库来处理数据的修改,不然如果我们使用 react 提供的 useStatehooks 函数去管理状态,每次对数据进行修改,都需要去替换一次整体,好在 react.js 有相关的库,那就是valtio.js

根据文档介绍用法,我们单独创建了一个 state.ts 然后利用 proxy 方法创建了这个数据状态,如下所示:

import { proxy } from "valtio";
import { CalendarItem } from "../../data/data.interface";

export const state = proxy<{ blockData: CalendarItem[] }>({
  blockData: [],
});

在页面当中我们还会使用 useSnapshot hooks 函数来取值进行展示,这都是官方文档提供的用法,这里不用细细讲解。popup 组件当中我们也可以分成两部分,一部分是头部,另一部分就是中间的表单项了,我们将父组件的 form 管理表单状态传下来,这样我们也可以基于 form 来管理状态。我们先来看具体的代码:

import { charToWord, dutyList, dutyValueTime } from "../const";
import React from "react";

import "./edit-excel.less";
import UserInfo from "./user-info";
import { CloseOutlined } from "@ant-design/icons";
import { Form, Select, Layout, Space, Button, FormInstance } from "antd";
import { state } from "./state";
import TimeInput from "./time-input";
import dayjs from "dayjs";
import { DutyShiftItem } from "../../data/data.interface";
export interface PopupProps {
  child: DutyShiftItem;
  index: number;
  childIndex: number;
  form: FormInstance;
  onSureHandler: (i: number, c: number) => void;
}
const { Header, Content } = Layout;
const Popup: React.FC<PopupProps> = (props) => {
  const { child, index, childIndex, form, onSureHandler } = props;
  return (
    <div className="trigger-popup">
      <Header
        style={{
          marginBottom: 15,
          display: "flex",
          justifyContent: "space-between",
          background: "#fff",
        }}
      >
        {child.isEdit ? "编辑" : "新增"}班次信息
        <CloseOutlined
          onClick={() => {
            state.blockData[index].shiftList[childIndex].visible = false;
          }}
          style={{ cursor: "pointer" }}
        />
      </Header>
      <Content>
        <Form
          form={form}
          labelCol={{ span: 8 }}
          wrapperCol={{ span: 16 }}
          onValuesChange={(v) => {
            if (v.name) {
              const date = state.blockData[index].shiftList[childIndex].date;
              const time = dutyValueTime[charToWord(v.name)].split("~");
              const dateTime = {
                start: dayjs(`${date} ${time[0]}`).unix(),
                end: dayjs(`${date} ${time[1]}`).unix(),
              };
              form.setFieldValue("time", dateTime);
            }
          }}
        >
          <Form.Item name="name" label="班次名称" rules={[{ required: true }]}>
            <Select style={{ width: 180 }}>
              {dutyList.map((item) => (
                <Select.Option key={item.value} value={item.value}>
                  {item.label}
                </Select.Option>
              ))}
            </Select>
          </Form.Item>
          <Form.Item name="time" label="值班时段">
            <TimeInput inputProps={{ style: { width: 180 } }} />
          </Form.Item>
          <Form.Item name="dutyPerson" label="值班人" valuePropName="username">
            <UserInfo />
          </Form.Item>
        </Form>
        <Space>
          <Button
            onClick={() => {
              state.blockData[index].shiftList[childIndex].visible = false;
            }}
          >
            取消
          </Button>
          <Button
            type="primary"
            onClick={() => onSureHandler(index, childIndex)}
          >
            确认
          </Button>
        </Space>
      </Content>
    </div>
  );
};

export default Popup;

可以看到,整体代码包含了 Header 和 Content 部分,Header 中,我们根据数据的 isEdit 来判断是否是编辑状态,通过给关闭按钮添加一个 onClick 事件从而执行关闭,我们将 popup 的关闭也是通过数据的一个 visible 来管理的,如此一来,我们只要修改这个状态就能达到关闭弹窗的目的。在 content 中,可以看到我们监听了 onValuesChange 事件,从而将选择的值班时间段转换成时间戳,然后使用 form.setFieldValue 来重新修改 form 状态,这样我们在父组件获取到的就是转换后的时间戳。

我们已经将一些基础组件和工具函数都写好了,接下来就是利用这些组件和工具函数来组合完成这个值班表了。接下来,我们来看最核心的部分,也就是值班表,可以看到值班表类似于一个 excel 表格,这也是 edit-excel 命名的由来。

edit-excel 组件

这个组件分成两部分,第一部分是组件代码,第二部分则是样式代码,这里并没有基于组件来调整样式,而是单独实现了样式,即 edit-excel.less。

样式代码

首先我们来看样式代码,如下所示:

@borderColor: rgba(229, 230, 235, 1);
@delectIconColor: rgba(62, 62, 62, 0.6);
@delectIconHoverColor: rgba(62, 62, 62, 0.8);
@prefix: duty-roster-excel;
.@{prefix} {
  margin-top: 15px;
  overflow: auto;
  &-row {
    display: flex;

    &:not(:first-child){
      .@{prefix}-cell {
        border-top: none;
      }
    }
  }
  &-cell {
    width: 100px;
    min-width: 100px;
    height: 100px;
    overflow: hidden;
    border: 1px solid @borderColor;
    cursor: pointer;
    &.no-cursor {
      cursor: initial;
    }
    &:first-child {
      width: 200px;
      min-width: 200px;
      height: 100px;
    }
    &:not(:first-child){
      border-left: none;
    }
    &.noName:hover .@{prefix}-add-block-btn{
      display: flex;
      transform: scale(1);
    }
    &.hasName:hover .@{prefix}-cell-content-delete-icon{
       display: inline-block;
    }
    &-content {
      position: relative;
      display: flex;
      height: 100%;
      padding: 7.5px;
      &.column-user {
        align-items: center;
      }
      .@{prefix}-add-block-btn {
        display: none;
        align-items: center;
        justify-content: center;
        width: 80px;
        height: 80px;
        border: 2px dashed @borderColor;
        transform: scale(0);
        transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1);
      }
      &-block {
        position: relative;
        width: 85px;
        height: 85px;
        padding: 5px;
        border: 1px solid currentColor;
      }
      &-empty-block {
        width: 85px;
        height: 85px;
      }
      &-delete-icon {
        position: absolute;
        top: 10px;
        right: 10px;
        display: none;
        color: @delectIconColor;
        &:hover {
           color: @delectIconHoverColor;
        }
      }
    }
    &-badge.ant-ribbon.ant-ribbon-placement-end {
      top: initial;
      right: 1px;
      bottom: 2px;
      .ant-ribbon-corner {
        display: none;
      }
   }
    &-day,
    &-week {
      text-align: center;
      margin: 1em 0;
    }
  }
}

.trigger-popup {
  min-width: 300px;
  padding: 10px;
  text-align: center;
  background-color: #fff;
  box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.15);
}

可以看到这是一个使用 LESS 预处理器编写的 CSS 样式代码,它定义了一个名为 duty-roster-excel 的前缀,并基于这个前缀定义了一系列的样式规则。

详细分析

我们可以针对代码做出如下的分析:

  • 变量定义:

    • @borderColor: 定义了边框颜色。
    • @delectIconColor@delectIconHoverColor: 分别定义了删除图标的默认颜色和悬停时的颜色。
    • @prefix:定义了样式的前缀,即 duty-roster-excel
  • .@{prefix}: 这是对 duty-roster-excel 类的样式变量使用。

    • margin-top: 15px;: 顶部外边距为 15px。
    • overflow: auto;: 内容溢出时启用滚动条。
  • .@{prefix}-row: 定义了行样式。

    • display: flex;: 使用 Flexbox 布局。
    • :not(:first-child) 选择器: 第一个子元素之外的元素。
  • .@{prefix}-cell: 定义了单元格样式。

    • 固定宽度和高度。
    • 边框样式。
    • 定义了.no-cursor类,用于覆盖默认的cursor: pointer;样式。
    • :first-child:not(:first-child) 选择器: 分别针对第一个子元素和第一个子元素之外的元素定义不同的样式。
    • .@{prefix}-cell-content: 定义了单元格内容区域的样式。

      • 包含.@{prefix}-add-block-btn.@{prefix}-cell-content-block.@{prefix}-cell-content-empty-block.@{prefix}-cell-content-delete-icon 的样式定义。
      • .@{prefix}-add-block-btn: 一个添加区块的按钮样式,初始状态下是隐藏的,并且具有缩放和过渡动画。
      • .@{prefix}-cell-content-block.@{prefix}-cell-content-empty-block: 定义了区块和空区块的样式。
      • .@{prefix}-cell-content-delete-icon: 定义了删除图标的样式,包括悬停时的颜色变化。
      • .@{prefix}-cell-badge.ant-ribbon.ant-ribbon-placement-end: 这部分是使用了 Ant Design 库的徽章(Ribbon)组件,并进行了样式覆盖。
    • .@{prefix}-cell-day.@{prefix}-cell-week: 用来定义单元格中日期和周数的样式。

总体来说,这段代码定义了一个类似 Excel 表格的布局,其中每个单元格(.@{prefix}-cell)都可以包含内容(.@{prefix}-cell-content)、添加区块的按钮(.@{prefix}-add-block-btn)、区块(.@{prefix}-cell-content-block)、空区块(.@{prefix}-cell-content-empty-block)以及删除图标(.@{prefix}-cell-content-delete-icon)。同时,它还包含了对 Ant Design 库中的徽章组件的样式覆盖。

组件代码

组件定义

接下来我们来看具体的组件代码,首先,我们需要定义 2 个 props 值,如下:

  • data: 传入进来的数据结构
  • onChange: 当用户做了一系列操作,例如,新增块,删除块,编辑块等,需要触发改变的回调函数。

具体代码如下:

import { CalendarItem } from "../../data/data.interface";
export interface EditExcelProps {
  data: {
    date: string;
    calendarList: CalendarItem[];
  };
  onChange: (v: CalendarItem[]) => void;
}
const EditExcel: React.FC<Partial<EditExcelProps>> = (props) => {
  // 具体代码
};

可以看到,我们的 data 是一个对象,包含 2 个值,即 date 和 calendarList,date 即日期,需要根据 date 来生成一定范围天数的日期,并展示日期和周,calendarList 为具体的值班数据,触发变动之后,回调函数的参数也是该值。CalendarItem 在前文已经说明,这里不再赘述。

dom 元素结构

接下来,我们先来确定组件元素结构,如下所示:

//...
const EditExcel: React.FC<Partial<EditExcelProps>> = (props) => {
  return (
    <div className="duty-roster-excel">
      <div className="duty-roster-excel-row">
        {/* 第一个cell占位用的 */}
        <div className="duty-roster-excel-cell no-cursor"></div>
        {days.map((item, index) => (
          <div
            className="duty-roster-excel-cell no-cursor"
            key={`${item}-${index}`}
            style={{
              background: [0, 6].includes(weeks[index]) ? "#f6f7fb" : "#fff",
            }}
          >
            <p className="duty-roster-excel-cell-day">{item}</p>
            <p className="duty-roster-excel-cell-week">
              {chineseWeekdays[weeks[index]]}
            </p>
          </div>
        ))}
      </div>
      {$state.blockData?.map((item, index) => (
        <div
          className="duty-roster-excel-row"
          key={`${item.username}-${index}`}
        >
          <div className="duty-roster-excel-cell">
            <div className="duty-roster-excel-cell-content column-user">
              <UserInfo username={item.username} />
            </div>
          </div>
          {item?.shiftList?.map((child, childIndex) => (
            <div
              className={`duty-roster-excel-cell ${
                child.name ? "hasName" : "noName"
              }`}
              key={`${child.id}-${childIndex}`}
              style={{
                background: [0, 6].includes(weeks[childIndex])
                  ? "#f6f7fb"
                  : "#fff",
              }}
            >
              <Badge.Ribbon
                text={
                  child.isShowEdit
                    ? "Edit"
                    : child.isAdd
                    ? "Add"
                    : child.isDelete
                    ? "Del"
                    : ""
                }
                className="duty-roster-excel-cell-badge"
              >
                <div className="duty-roster-excel-cell-content">
                  <Trigger
                    trigger="click"
                    showArrow
                    popupVisible={child.visible}
                    style={{ width: 350 }}
                    popup={() => (
                      <Popup
                        form={form}
                        child={child}
                        index={index}
                        childIndex={childIndex}
                        onSureHandler={onSureHandler}
                      />
                    )}
                    alignPoint
                    clickOutsideToClose={false}
                    onClickOutside={() => {
                      state.blockData[index].shiftList[childIndex].visible =
                        false;
                    }}
                  >
                    <AddBlockButton
                      onClick={() => onAddBlockHandler(index, childIndex)}
                    />
                    <Tooltip title="单击可以对班次信息进行修改">
                      <div
                        className="duty-roster-excel-cell-content-block"
                        style={{
                          display: child.name ? "block" : "none",
                          color: getBlockColor?.(child.name)?.color,
                          background: getBlockColor?.(child.name)?.bgColor,
                        }}
                        onClick={() => {
                          onEditBlockHandler(index, childIndex);
                        }}
                      >
                        {child.name}
                      </div>
                    </Tooltip>
                    {/* 用于删除后展示徽标的空块,主要作用是不会让徽标的展示出问题 */}
                    {!child.name && (
                      <div className="duty-roster-excel-cell-content-empty-block"></div>
                    )}
                  </Trigger>
                  {child.name && (
                    <Tooltip title="删除班次信息">
                      <Popconfirm
                        title="温馨提示"
                        description="确定要删除该班次信息吗"
                        onConfirm={() => {
                          onDeleteHandler(index, childIndex);
                        }}
                        okText="确认"
                        cancelText="取消"
                      >
                        <DeleteOutlined className="duty-roster-excel-cell-content-delete-icon" />
                      </Popconfirm>
                    </Tooltip>
                  )}
                </div>
              </Badge.Ribbon>
            </div>
          ))}
        </div>
      ))}
    </div>
  );
};

整体 dom 结构可以分成 2 个大部分:

  1. 显示范围日期和周的行/列。
  2. 显示值班数据的行/列。
日期/周展示

我们先来看第一部分的 dom 结构,也就是我们的第一行,第一行第一列是一个空列,因此写了一个空元素当作占位用,如下所示:

<div className="duty-roster-excel-cell no-cursor"></div>

这里第一行我们是不需要有任何操作的,因此不需要有鼠标手型效果,添加了一个 no-cursor 的类名意义就在这里,接下来就是遍历范围天数生成展示日期和周的元素,并且判断是否是工作日来增加背景色区分,返回的 weeks 是一个索引值数组,从 0~6 分别代表周日~周六。这也是为什么判断值是[0,6]。同样的我们定义 chineseWeekdays,来将周数值转换成中文展示。

我们的 days 是通过 getNextFourteenDate 方法生成的日期数据,如下所示:

// ...
import { getNextFourteenDate } from "../util";
const EditExcel: React.FC<Partial<EditExcelProps>> = (props) => {
  //...
  const days = useMemo(() => getNextFourteenDate(data.date), [data]);
  //...
};

这里特别注意 weeks 踩了一个坑,也就是需要传入具体的日期格式,而不能根据 days 来进行转换,否则得到的不是正确的周。这也是如下这行代码的意义所在:

import dayjs from "dayjs";
// ...
import { getNextFourteenDate } from "../util";
const EditExcel: React.FC<Partial<EditExcelProps>> = (props) => {
  //...
  // 这行代码单独读取周
  const weeks = getNextFourteenDate(data.date, "YYYY-MM-DD").map((item) =>
    dayjs(item).day()
  );
  //...
};

表格第一行仅仅只有展示的作用,到这里基本就大功告成了,接下来我们来看我们的具体值班,也就是第二部分。

值班表

第二部分的内容也是比较多的。我们可以分成 2 个大部分,如下:

  1. 每行第一列仅展示值班用户信息。
  2. 每行后续列的值班数据。

第一部分也比较好理解,添加一列元素,然后每一列具体内容使用封装好的 userinfo 组件来展示用户信息。接下来我们来看第二部分,值班块,我们每一列都需要根据是否有名字来确定是否是空块,如果是空块,那就有悬浮展示添加按钮的操作,并且我们添加了 hasName 与 noName 类名。我们可以根据这两个类名做一些操作,例如悬浮显示删除图标。对于删除按钮就是一个小图标,我们还添加了二次确认对话框组件来确定是否真的要删除。对于每一个操作(新增,编辑,删除),我们都会添加一个徽标进行展示,这里由于徽标是定位的,因此我们也需要在删除的时候添加一个空块来修复徽标的定位展示。

渲染数据我们是通过来 valtio 管理的,这是一个可以让 react 数据状态变成响应式数据的库,如下所示:

import { useSnapshot } from "valtio";
const $state = useSnapshot(state);

trigger 触发器组件的作用就是点击可以在块旁边出现一个表单弹框组件,并且点击这个组件之外的区域会关闭表单弹框组件,这个组件的展示通过数据的 visible 字段来控制,然后还配置了一些属性,以及 onClickOutside 回调。

这里为了增加用户体验,我们的每一个块会根据类名添加对应的样式,如下所示:

// ...
import React, { useCallback, useEffect, useMemo } from "react";
import { WordKey, charToWord, dutyTimeColor } from "../const";
const EditExcel: React.FC<Partial<EditExcelProps>> = (props) => {
  //...
  const getBlockColor = useCallback((v: string) => {
    return dutyTimeColor[charToWord(v as WordKey)];
  }, []);
  //...
};

dom 结构完成之后,就是我们的具体逻辑了,总的说来,分为新增,编辑,删除以及确认的逻辑。如下所示:

// 新增
const onAddBlockHandler = (index: number, childIndex: number) => {
  state.blockData[index].shiftList[childIndex].visible = true;
  state.blockData[index].shiftList[childIndex].isEdit = false;
  form.setFieldsValue({
    dutyPerson: state.blockData[index].username,
    time: {},
    name: "",
  });
};
// 编辑
const onEditBlockHandler = (index: number, childIndex: number) => {
  state.blockData[index].shiftList[childIndex].visible = true;
  state.blockData[index].shiftList[childIndex].isEdit = true;
  const data = state.blockData[index].shiftList[childIndex];
  form.setFieldsValue({
    name: data.name,
    time: data.time,
    dutyPerson: state.blockData[index].username,
  });
};
// 确认
const onSureHandler = (index: number, childIndex: number) => {
  form.validateFields().then((values) => {
    const { name, time } = values;
    state.blockData[index].shiftList[childIndex].name = name;
    state.blockData[index].shiftList[childIndex].time = {
      start: time?.start > 0 ? time.start : 0,
      end: time?.end > 0 ? time.end : 0,
    };
    state.blockData[index].shiftList[childIndex].visible = false;
    if (!state.blockData[index].shiftList[childIndex].isEdit) {
      state.blockData[index].shiftList[childIndex].isAdd = true;
    } else {
      state.blockData[index].shiftList[childIndex].isShowEdit = true;
    }
    onChange?.(state.blockData);
  });
};
// 删除
const onDeleteHandler = (index: number, childIndex: number) => {
  state.blockData[index].shiftList[childIndex].name = "";
  state.blockData[index].shiftList[childIndex].time = {};
  state.blockData[index].shiftList[childIndex].isDelete = true;
  state.blockData[index].shiftList[childIndex].isShowEdit = false;
  state.blockData[index].shiftList[childIndex].isAdd = false;
  onChange?.(state.blockData);
};

可以看到,这四种操作都是对值班数据的更改,因此我们就需要将修改后的数据回调出去,我们根据索引值来确定是在哪里操作数据。点击新增和编辑,我们都需要将表单弹窗打开,因此也就需要修改 visible 为 true,并且对应的操作状态,我们也需要设置为 true,如 isAdd,isShowEdit,isDelete,这些都是用来控制徽标展示的数据。这里还有一个 isEdit 主要用来区分最后保存是编辑操作还是新增操作的。对于新增,我们需要将回显值给清空掉,这也就是如下这行代码的意义:

const data = state.blockData[index].shiftList[childIndex];
form.setFieldsValue({
  name: data.name,
  time: data.time,
  dutyPerson: state.blockData[index].username,
});

同理对于编辑,我们也需要将值给回显出来。最后就是我们的如下这一行代码:

useEffect(() => {
  if (!_.isEqual(state.blockData, data.calendarList)) {
    state.blockData = data.calendarList?.map((item) => ({
      username: item.username,
      shiftList: item.shiftList.map((item) => ({
        ...item,
        visible: false,
        isDelete: _.isBoolean(item.isDelete) ? item.isDelete : false,
        isEdit: _.isBoolean(item.isEdit) ? item.isEdit : false,
        isShowEdit: _.isBoolean(item.isShowEdit) ? item.isShowEdit : false,
        isAdd: _.isBoolean(item.isAdd) ? item.isAdd : false,
      })),
    }));
  }
}, [data.calendarList]);

由于从 props 传下来的值班数据并没有操作相关的字段,因此这里我们就需要单独修改一下数据,这里用了 lodash 的 isEqual 方法用来判断,如果两者数据不相同,才进行修改。

duty-roster 组件

edit-excel 组件实现完成之后,接下来,我们将所有的组件整合一下,就得到了我们这个组件的诞生,我们先来看组件的整体代码:

样式代码

.duty-roster-container {
  position: relative;
  .edit-table {
    margin-top: 15px;
  }
}

.duty-roster-flex-center {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  min-height: 300px;
  margin-top: 15px;
}

.user-info {
  display: flex;
  align-items: center;
  .ant-card-meta-avatar {
    width: 30px;
    height: 30px;
    margin-right: 5px;
    .ant-avatar {
      width: 100%;
      height: 100%;
    }
  }
  .ant-card-meta-detail {
    text-align: left;
  }
}

.ant-select-selection-item-content {
  .user-info {
    height: 20px;
    width: 80px;
    font-size: 12px;
  }
}

组件代码

import {
  DatePicker,
  Layout,
  Space,
  Row,
  Button,
  Modal,
  message,
  DatePickerProps,
  Spin,
} from "antd";
import React, { useEffect } from "react";
import "./duty-roster.less";
import { formatDateByTimeStamp } from "./util";
import { useLocalStorageState, useRequest, useSafeState } from "ahooks";
import EditExcel from "./components/edit-excel";
import { CalendarItem } from "../data/data.interface";
import dayjs from "dayjs";
import _ from "lodash";
import { DutySchedules } from "../data/data.interface";
import { query, update } from "../api/request";
import { DEFAULT_RANGE_DATE } from "./const";
import SearchUserInput from "./components/search-user-input";

const { Header, Content } = Layout;
export interface DutyRosterProps {
  isShowSearch: boolean;
  searchProps: any;
  value?: string;
  defaultValue?: string;
  onChange?: (v: string) => void;
  datePickerProps?: DatePickerProps;
  calendarFieldNames?: {
    user?: string;
    dutyName?: string;
    dutyTime?: string;
    dutyUser?: string;
  };
}

const DutyRoster: React.FC<Partial<DutyRosterProps>> = (props) => {
  const [modal, contextHolder] = Modal.useModal();
  const {
    isShowSearch = true,
    // searchProps = {},
    datePickerProps = {},
    defaultValue = formatDateByTimeStamp(new Date(), "YYYY-MM-DD"),
    onChange,
  } = props;
  const onSuccess = (calendarList: DutySchedules) => {
    // 代码的时间复杂度是O(n),因为对于calendarList中的每个item,我们只对shiftList进行一次遍历,并且使用Set的add方法来添加用户名,这是一个常数时间的操作。
    // 最后,如果需要的话,我们将Set对象转换回数组,Array.from方法的时间复杂度也是线性的,但只执行一次,所以不会改变总体时间复杂度。
    // 注意:Set会保持元素的唯一性,因此不需要显式地检查usernameSet是否包含某个用户名,因为add方法会自动忽略重复的元素。
    const usernameSet = new Set<string>();
    calendarList.forEach((item) =>
      item?.shiftList?.forEach((shift) => usernameSet.add(shift.username))
    );
    const usernameList = Array.from(usernameSet);
    // 处理数据格式,以满足前端定义的渲染逻辑格式
    const data = calendarList?.reduce<CalendarItem[]>((res) => {
      res = usernameList?.map((child) => ({
        username: child,
        shiftList: calendarList
          ?.map((calendar) => {
            const { date, shiftList: sl } = calendar;
            const find = sl.find((_) => _.username === child);
            if (find) {
              const copyFind = _.cloneDeep(find) as Partial<typeof find>;
              delete copyFind.username;
              return {
                ...copyFind,
                date,
              };
            } else {
              return { name: "", date, time: { start: 0, end: 0 } };
            }
          })
          .sort((a, b) => {
            const dateA = new Date(a.date).getTime(),
              dateB = new Date(b.date).getTime();
            return dateA - dateB;
          })
          .map((child, index) => ({
            id: index + 1, // 加个id
            ...child,
          })),
      })) as CalendarItem[];
      return res;
    }, []);
    setOriginDutyData(data);
    if (isChangeDutyData || !localDutyData?.length) {
      setLocalDutyData(data);
    }
  };
  const { runAsync: queryDutySchedules } = useRequest(query, {
    manual: true,
    onSuccess(res) {
      onSuccess(res.data);
    },
  });
  const { runAsync: updateDutySchedules } = useRequest(update, {
    manual: true,
    onSuccess(res) {
      message.success("更新值班表信息成功!");
      requestDutyData(dutyDate, res);
      setIsChangeDutyData(true);
      setChangeCount(0);
    },
  });
  const [changeCount, setChangeCount] = useLocalStorageState<number>(
    "localDutyDataCount",
    { defaultValue: 0 }
  );
  const [search, setSearch] = useSafeState<string[]>([]);
  const [originDutyData, setOriginDutyData] = useSafeState<CalendarItem[]>([]);
  const [dutyDate, setDutyDate] = useSafeState(defaultValue);
  const [isChangeDutyData, setIsChangeDutyData] = useLocalStorageState(
    "isChangeLocalDutyData",
    { defaultValue: true }
  );
  const [loading, setLoading] = useSafeState(false);
  const [localDutyData, setLocalDutyData] = useLocalStorageState<
    CalendarItem[]
  >("localDutyData", { defaultValue: [...originDutyData] });
  const onChangeHandler = (data: CalendarItem[]) => {
    let count = 0;
    data.forEach((item) => {
      const { shiftList } = item;
      shiftList.forEach((child) => {
        if (child.isAdd || child.isDelete || child.isShowEdit) {
          count++;
        }
      });
    });
    setChangeCount(count);
    setLocalDutyData(data);
    setIsChangeDutyData(false);
  };
  const requestDutyData = (date: string, res?: DutySchedules) => {
    setLoading(true);
    queryDutySchedules({
      date,
      day: DEFAULT_RANGE_DATE,
    })
      .then(() => setLoading(false))
      .catch(() => setLoading(false));
    if (res) {
      onSuccess(res);
    }
  };
  const onReleaseHandler = () => {
    modal.confirm({
      title: "确认发布",
      content: "确认发布新的值班表?发布后原值班表内容将被覆盖",
      onOk() {
        const calendarList = localDutyData?.reduce<DutySchedules[]>(
          (res, item) => {
            const { shiftList } = item;
            res = shiftList.map((shift) => {
              const { date } = shift;
              const newItem = {
                date,
                shiftList: localDutyData
                  .map((duty) => {
                    const find = duty.shiftList.find((_) => _.date === date);
                    if (find) {
                      const { name, time } = find;
                      return {
                        name,
                        time,
                        username: duty.username,
                      };
                    }
                  })
                  .filter((item) => item?.name),
              };
              return newItem;
            }) as any;
            return res;
          },
          []
        );
        updateDutySchedules(calendarList as any);
      },
      onCancel() {},
      okText: "确认",
      cancelText: "取消",
    });
  };
  const onBeforeUnloadHandler = (e: Event) => {
    e.preventDefault();
    e.returnValue = false;
    setChangeCount(0);
    setIsChangeDutyData(true);
  };
  useEffect(() => {
    requestDutyData(dutyDate);
    window.addEventListener("beforeunload", onBeforeUnloadHandler);
    return () => {
      window.removeEventListener("beforeunload", onBeforeUnloadHandler);
    };
  }, []);
  const onSearchHandler = (v: string[]) => {
    setSearch(v);
    if (v.length) {
      const filterDutyData = originDutyData.filter((duty) =>
        v.some((item) => duty.username.includes(item))
      );
      setLocalDutyData(filterDutyData);
    } else {
      setLocalDutyData(originDutyData);
    }
  };
  const onChangeDutyHandler = (v: dayjs.Dayjs) => {
    const successHandler = () => {
      setDutyDate(v.toISOString() ?? defaultValue);
      requestDutyData(v.toISOString() ?? defaultValue);
      onChange?.(v.toISOString());
      setChangeCount(0);
    };
    if (changeCount === 0) {
      successHandler();
      return;
    }
    modal.confirm({
      title: "温馨提示",
      content:
        "当前仅能缓存一个周期的数据,切换日期,缓存数据将会被覆盖,确认即同意覆盖,否则请先发布再切换!",
      onOk() {
        successHandler();
        setIsChangeDutyData(true);
      },
      onCancel() {},
      okText: "确认",
      cancelText: "取消",
    });
  };
  return (
    <div className="duty-roster-container">
      {contextHolder}
      <Header style={{ background: "#fff" }}>
        <Row justify="space-between" align="middle">
          <Space align="center">
            {isShowSearch && (
              <SearchUserInput
                style={{ minWidth: 300 }}
                onSearch={onSearchHandler}
              />
            )}
            <DatePicker
              style={{ width: 200 }}
              {...datePickerProps}
              defaultValue={dayjs(defaultValue)}
              value={dayjs(dutyDate)}
              onChange={(v) => onChangeDutyHandler(v)}
            />
          </Space>
          <Space align="center">
            <Button
              onClick={() => {
                modal.confirm({
                  title: "温馨提示",
                  content: "确认恢复后,未发布的修改内容将丢失!",
                  onOk() {
                    const data = originDutyData[0].shiftList.sort((a, b) => {
                      const dateA = new Date(a.date!).getTime(),
                        dateB = new Date(b.date!).getTime();
                      return dateA - dateB;
                    });
                    setDutyDate(data[0].date!);
                    setLocalDutyData(originDutyData);
                    setChangeCount(0);
                    setSearch([]);
                  },
                  onCancel() {},
                  okText: "确认",
                  cancelText: "取消",
                });
              }}
            >
              恢复
            </Button>
            <Button type="primary" onClick={onReleaseHandler}>
              发布({changeCount})
            </Button>
          </Space>
        </Row>
      </Header>
      <Content className={`${loading ? "duty-roster-flex-center" : ""}`}>
        <Spin tip="值班表数据加载中,请耐心等待...." spinning={loading}>
          <EditExcel
            data={{ date: dutyDate, calendarList: localDutyData! }}
            onChange={onChangeHandler}
          />
        </Spin>
      </Content>
    </div>
  );
};

export default DutyRoster;

接下来我们来一步一步的解析这个组件代码,关于样式代码,只是一些简单的布局调整,这里也没什么好说的。我们主要来看具体的组件代码,组件代码可以拆分成如下几个部分:

  • 头部

    • 用户搜索
    • 日期选择
    • 操作栏

      • 恢复
      • 发布
  • 身体

    • 值班表

按照如上的区分,我们的 dom 结构也比较好理解了,主要就是一些交互逻辑,值班表的整体交互逻辑我们已经实现了,这里主要调用 onChange 事件,然后将更改后的数据更新即可。发布的时候会显示操作块数,因此可以根据 isDelete 和 isAdd 以及 isEdit 字段来统计操作块数。当我们对值班表有操作的时候,这里就会添加二次确认弹框。

这里模拟数据请求,因此添加了一个加载中的交互,如果是加载中,我们需要将加载中的样式水平垂直居中。即如下代码的含义:

<Content className={`${loading ? "duty-roster-flex-center" : ""}`}>
  <Spin tip="值班表数据加载中,请耐心等待...." spinning={loading}>
    <EditExcel
      data={{ date: dutyDate, calendarList: localDutyData! }}
      onChange={onChangeHandler}
    />
  </Spin>
</Content>

接下来,就是点击回复和点击发布,用户输入搜索过滤用户值班信息,以及切换日期触发数据的重新请求等功能,还有就是当用户有了交互改动的之后,如果关闭浏览器或者刷新当前页,需要有相应的交互,即 beforeunload 事件的执行逻辑。

这里还涉及到了算法知识,如下:

const usernameSet = new Set<string>();
calendarList.forEach((item) =>
  item?.shiftList?.forEach((shift) => usernameSet.add(shift.username))
);
const usernameList = Array.from(usernameSet);

这行代码的意思就是从数据当中提取出用户来,整个操作是 O(n)的时间复杂度,正如注释中所说,所以这里需要单独说一下,因为我的第一版本是用的 2 个 for 循环做的操作,后面将这段代码优化了一下。

然后这里还有一个难以理解的地方,就是数据的转换逻辑,可以这么说数据的转换才是这个组件的核心难点,也是不好理解的,需要理清源数据与转换数据的结构,才能写出转换代码来。如下所示:

const data = calendarList?.reduce<CalendarItem[]>((res) => {
  res = usernameList?.map((child) => ({
    username: child,
    shiftList: calendarList
      ?.map((calendar) => {
        const { date, shiftList: sl } = calendar;
        const find = sl.find((_) => _.username === child);
        if (find) {
          const copyFind = _.cloneDeep(find) as Partial<typeof find>;
          delete copyFind.username;
          return {
            ...copyFind,
            date,
          };
        } else {
          return { name: "", date, time: { start: 0, end: 0 } };
        }
      })
      .sort((a, b) => {
        const dateA = new Date(a.date).getTime(),
          dateB = new Date(b.date).getTime();
        return dateA - dateB;
      })
      .map((child, index) => ({
        id: index + 1, // 加个id
        ...child,
      })),
  })) as CalendarItem[];
  return res;
}, []);

我们先根据用户名将将值班数据匹配上,然后再根据日期来排序,最后再给每个数据添加一个 id,这里添加 id 方便列表渲染,也可以根据 id 来进行删除操作(当然这里是没有这么做的),在这里仅方便列表渲染,相信大家也经常见过,react 的 unique key 的 warning。

而在发布之后,我们也对数据进行了一次转换,这也是和后端沟通不合理导致的,其实如果前端数据和后端数据结构设计保持一致的话,前端这里就不需要进行转换,但是前端这里需要类似 isAdd 这些来判断是否新增,而后端是不需要这些数据字段的,因此这里数据转换是必不可少的。

总结

以上所有就是我们的值班表组件的实现,可以看到,通过这么一个实际业务需求,我们也学到了不少知识点,总结如下:

  1. react + vite + typescript + less + css
  2. antd (原版使用的是 acro design),不过这里也引入了 acro design,因为要用到 acro design 提供的 Trigger 组件。
  3. lodash
  4. day.js
  5. ahooks
  6. valtio
  7. query-string
  8. axios

感兴趣的读者可以抽空自行实现一版,对比一下代码看看。我实现的代码在线示例可以前往这里查看。


夕水
5.3k 声望5.7k 粉丝

问之以是非而观其志,穷之以辞辩而观其变,资之以计谋而观其识,告知以祸难而观其勇,醉之以酒而观其性,临之以利而观其廉,期之以事而观其信。