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

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

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

edit-excel 组件

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

样式代码

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

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: 当用户做了一系列操作,例如,新增块,删除块,编辑块等,需要触发改变的回调函数。

具体代码如下:

tsx 复制代码
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 元素结构

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

tsx 复制代码
//...
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 结构,也就是我们的第一行,第一行第一列是一个空列,因此写了一个空元素当作占位用,如下所示:

html 复制代码
<div className="duty-roster-excel-cell no-cursor"></div>

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

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

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

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

tsx 复制代码
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 数据状态变成响应式数据的库,如下所示:

tsx 复制代码
import { useSnapshot } from "valtio";
const $state = useSnapshot(state);

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

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

tsx 复制代码
// ...
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 结构完成之后,就是我们的具体逻辑了,总的说来,分为新增,编辑,删除以及确认的逻辑。如下所示:

tsx 复制代码
// 新增
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 主要用来区分最后保存是编辑操作还是新增操作的。对于新增,我们需要将回显值给清空掉,这也就是如下这行代码的意义:

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

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

tsx 复制代码
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 组件实现完成之后,接下来,我们将所有的组件整合一下,就得到了我们这个组件的诞生,我们先来看组件的整体代码:

样式代码

less 复制代码
.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;
  }
}

组件代码

tsx 复制代码
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 字段来统计操作块数。当我们对值班表有操作的时候,这里就会添加二次确认弹框。

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

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

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

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

tsx 复制代码
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 循环做的操作,后面将这段代码优化了一下。

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

tsx 复制代码
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

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

相关推荐
Apifox3 分钟前
如何在 Apifox 中通过 Runner 运行包含云端数据库连接配置的测试场景
前端·后端·ci/cd
-代号95277 分钟前
【JavaScript】十四、轮播图
javascript·css·css3
树上有只程序猿30 分钟前
后端思维之高并发处理方案
前端
庸俗今天不摸鱼1 小时前
【万字总结】前端全方位性能优化指南(十)——自适应优化系统、遗传算法调参、Service Worker智能降级方案
前端·性能优化·webassembly
QTX187301 小时前
JavaScript 中的原型链与继承
开发语言·javascript·原型模式
黄毛火烧雪下1 小时前
React Context API 用于在组件树中共享全局状态
前端·javascript·react.js
Apifox1 小时前
如何在 Apifox 中通过 CLI 运行包含云端数据库连接配置的测试场景
前端·后端·程序员
一张假钞1 小时前
Firefox默认在新标签页打开收藏栏链接
前端·firefox
高达可以过山车不行1 小时前
Firefox账号同步书签不一致(火狐浏览器书签同步不一致)
前端·firefox
m0_593758101 小时前
firefox 136.0.4版本离线安装MarkDown插件
前端·firefox