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

410 阅读7分钟

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 的内部逻辑比较多,因此这里打算再写一篇文章来详解,这篇文章就到此为止了,感谢大家观看。