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 个大部分:
- 显示范围日期和周的行/列。
- 显示值班数据的行/列。
日期/周展示
我们先来看第一部分的 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 个大部分,如下:
- 每行第一列仅展示值班用户信息。
- 每行后续列的值班数据。
第一部分也比较好理解,添加一列元素,然后每一列具体内容使用封装好的 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 这些来判断是否新增,而后端是不需要这些数据字段的,因此这里数据转换是必不可少的。
总结
以上所有就是我们的值班表组件的实现,可以看到,通过这么一个实际业务需求,我们也学到了不少知识点,总结如下:
- react + vite + typescript + less + css
- antd (原版使用的是 acro design),不过这里也引入了 acro design,因为要用到 acro design 提供的 Trigger 组件。
- lodash
- day.js
- ahooks
- valtio
- query-string
- axios
感兴趣的读者可以抽空自行实现一版,对比一下代码看看。我实现的代码在线示例可以前往这里查看。