Excuse me? 产品让我实现一个值班表 (中)
在上一篇文章,我们对整个值班表做了分析和一些准备工作,接下来,我们就根据模块一步一步实现整个值班表。
添加块
首先我们来看添加块组件,这个组件很简单,就是一个 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 状态,这样我们在父组件获取到的就是转换后的时间戳。
结束
以上的所有组件都只是一些开胃小菜,接下来的 edit-excel 组件和最终合并的组件才是重头戏,由于 edit-excel 组件和 index.tsx 的内部逻辑比较多,因此这里打算再写一篇文章来详解,这篇文章就到此为止了,感谢大家观看。