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

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

相关推荐
夏河始溢2 分钟前
一七八、Node.js PM2使用介绍
前端·javascript·node.js·pm2
记忆深处的声音2 分钟前
vue2 + Element-ui 二次封装 Table 组件,打造通用业务表格
前端·vue.js·代码规范
陈随易3 分钟前
兔小巢收费引发的论坛调研Node和Deno有感
前端·后端·程序员
熊的猫17 分钟前
webpack 核心模块 — loader & plugins
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
速盾cdn24 分钟前
速盾:vue的cdn是干嘛的?
服务器·前端·网络
四喜花露水1 小时前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy1 小时前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie2 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust2 小时前
css:基础
前端·css
帅帅哥的兜兜2 小时前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3