Excuse me? 产品让我实现一个值班表
产品经理让我实现一个值班表,然而我找遍了开源项目,都没有找到符合设计的交互需求,没有办法,我只好自己实现了一个值班表。如下图所示:
ps: 值班表的实现采用 react.js 技术栈,所以也将以 react.js 为主来讲解。
实现模块分析
根据交互图,我将整个值班表拆分出了如下模块:
- 标题
- 用户搜索框
- 日期选择
按钮组
- 恢复按钮
- 发布按钮
- 值班表
- 用户展示信息
- 值班表头。
值班表体,分为行和列,每一列具体可以看作是一个块。
- 块。
- 徽标
- 新增块。
- 编辑块。
- 删除块。
首先我们来分析一下每一个模块要实现的具体功能有哪些。
首先是标题部分,用户搜索框,输入一个用户名或者是用户的邮箱号,然后返回用户,然后选择用户就可以只显示当前用户的值班表。其次是切换日期部分,切换日期之后,值班表将变成以选中日期为主,后 14 天范围的值班表。由于这里的值班表数据是模拟的,以及这里需要调用接口,由后端提供 14 天范围的数据,因此这个功能实际上是由后端来完成的,不过前端也完成了相应的调接口传参数的逻辑。
ps: 由于这里涉及到了缓存数据,因此几乎每个操作,都添加了二次确认的提示。
接下来是恢复按钮,恢复的逻辑也比较简单,就是添加一个二次确认,然后如果点击了确定,我们实际上就是返回调用接口的原始数据即可,然后就是发布按钮,发布按钮的逻辑也不复杂,同样是给一个二次确认,然后再将最新的值班表的数据当作参数传给后端,由后端来修改并返回。
ps: 本次只展示了前端的实现,因此数据都是在前端模拟的,因此发布按钮只是相当于做了一个简单的逻辑。
标题组件的交互功能逻辑算是完了,接下来我们来看值班表,首先是值班表头,值班表头展示 14 天的日期数据以及当前是星期几。其次是值班表体,表体第一列展示的是用户信息组件,因此这里也需要单独实现一个用户信息组件。
注意: 实际业务场景中已经有封装好了的用户信息组件,这里只是做的一个模拟实现。
然后其它列就是展示的值班信息,值班信息每一列我把它叫做块,对于块,首先是展示值班信息,如果没有值班信息,就是一个空块,如果有值班信息,那就要以对应的颜色区分(字体色,背景色以及边框色)。然后就是对块的增删改查了,如果有值班信息,那么就是编辑,没有就出现显示新增的块,悬浮上去即出现,然后单击就是新增块,新增块和编辑块都是出现一个气泡确认提示框,气泡确认提示框里的内容是三个表单项,包含确认取消按钮以及关闭按钮点击关闭气泡确认提示框的逻辑,里面的表单项分成三个,即选择值班名称,值班时间段,以及对应的值班人。只有值班名称需要由用户选择,用户选择了之后,根据值班名称来展示出对应的值班时间段,值班时间段是不需要用户修改的,然后值班人也是不需要用户修改的,只是做一个展示用。编辑值班信息与新增值班信息相比只是多了一个表单数据回填,另外只有不是空块才会是单击块变成编辑值班信息,否则就是新增值班信息。最后就是删除值班信息,块的右上角出现一个删除图标,如果块含有值班信息,才可以删除。
这里也做了不少交互优化,首先就是单击块可以编辑信息会出现 tooltip 提示信息,然后是删除也会有,并且单击也会有二次确认提示,而且删除图标是鼠标悬浮块之后出现。然后就是如果用户做了增删改的操作,都会展示一个徽标组件,标注用户修改过,这是因为这里会有数据缓存,用户如果做了改动,要给予用户一些反馈,并且刷新或者是关闭浏览器的时候要有所反馈。
以上是对所有的交互做了一个具体的分析,接下来我们来看本次实现所需要的技术栈。
所用到的技术栈
本次值班表的实现用到的技术栈也有不少,分别列出如下:
- react + vite + typescript + less + css
- antd (原版使用的是 acro design),不过这里也引入了 acro design,因为要用到 acro design 提供的 Trigger 组件。
- lodash
- day.js
- ahooks
- valtio
- query-string
- 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 个大部分:
- 显示范围日期和周的行/列。
- 显示值班数据的行/列。
日期/周展示
我们先来看第一部分的 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 个大部分,如下:
- 每行第一列仅展示值班用户信息。
- 每行后续列的值班数据。
第一部分也比较好理解,添加一列元素,然后每一列具体内容使用封装好的 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 这些来判断是否新增,而后端是不需要这些数据字段的,因此这里数据转换是必不可少的。
总结
以上所有就是我们的值班表组件的实现,可以看到,通过这么一个实际业务需求,我们也学到了不少知识点,总结如下:
- react + vite + typescript + less + css
- antd (原版使用的是 acro design),不过这里也引入了 acro design,因为要用到 acro design 提供的 Trigger 组件。
- lodash
- day.js
- ahooks
- valtio
- query-string
- axios
感兴趣的读者可以抽空自行实现一版,对比一下代码看看。我实现的代码在线示例可以前往这里查看。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。