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

1,106 阅读16分钟

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

产品经理让我实现一个值班表,然而我找遍了开源项目,都没有找到符合设计的交互需求,没有办法,我只好自己实现了一个值班表。如下图所示:

duty-roster.png

ps: 值班表的实现采用 react.js 技术栈,所以也将以 react.js 为主来讲解。

实现模块分析

根据交互图,我将整个值班表拆分出了如下模块:

  • 标题
  • 用户搜索框
  • 日期选择
  • 按钮组
    • 恢复按钮
    • 发布按钮
  • 值班表
  • 用户展示信息
  • 值班表头。
  • 值班表体,分为行和列,每一列具体可以看作是一个块。
    • 块。
    • 徽标
    • 新增块。
    • 编辑块。
    • 删除块。

首先我们来分析一下每一个模块要实现的具体功能有哪些。

首先是标题部分,用户搜索框,输入一个用户名或者是用户的邮箱号,然后返回用户,然后选择用户就可以只显示当前用户的值班表。其次是切换日期部分,切换日期之后,值班表将变成以选中日期为主,后 14 天范围的值班表。由于这里的值班表数据是模拟的,以及这里需要调用接口,由后端提供 14 天范围的数据,因此这个功能实际上是由后端来完成的,不过前端也完成了相应的调接口传参数的逻辑。

ps: 由于这里涉及到了缓存数据,因此几乎每个操作,都添加了二次确认的提示。

接下来是恢复按钮,恢复的逻辑也比较简单,就是添加一个二次确认,然后如果点击了确定,我们实际上就是返回调用接口的原始数据即可,然后就是发布按钮,发布按钮的逻辑也不复杂,同样是给一个二次确认,然后再将最新的值班表的数据当作参数传给后端,由后端来修改并返回。

ps: 本次只展示了前端的实现,因此数据都是在前端模拟的,因此发布按钮只是相当于做了一个简单的逻辑。

标题组件的交互功能逻辑算是完了,接下来我们来看值班表,首先是值班表头,值班表头展示 14 天的日期数据以及当前是星期几。其次是值班表体,表体第一列展示的是用户信息组件,因此这里也需要单独实现一个用户信息组件。

注意: 实际业务场景中已经有封装好了的用户信息组件,这里只是做的一个模拟实现。

然后其它列就是展示的值班信息,值班信息每一列我把它叫做块,对于块,首先是展示值班信息,如果没有值班信息,就是一个空块,如果有值班信息,那就要以对应的颜色区分(字体色,背景色以及边框色)。然后就是对块的增删改查了,如果有值班信息,那么就是编辑,没有就出现显示新增的块,悬浮上去即出现,然后单击就是新增块,新增块和编辑块都是出现一个气泡确认提示框,气泡确认提示框里的内容是三个表单项,包含确认取消按钮以及关闭按钮点击关闭气泡确认提示框的逻辑,里面的表单项分成三个,即选择值班名称,值班时间段,以及对应的值班人。只有值班名称需要由用户选择,用户选择了之后,根据值班名称来展示出对应的值班时间段,值班时间段是不需要用户修改的,然后值班人也是不需要用户修改的,只是做一个展示用。编辑值班信息与新增值班信息相比只是多了一个表单数据回填,另外只有不是空块才会是单击块变成编辑值班信息,否则就是新增值班信息。最后就是删除值班信息,块的右上角出现一个删除图标,如果块含有值班信息,才可以删除。

这里也做了不少交互优化,首先就是单击块可以编辑信息会出现 tooltip 提示信息,然后是删除也会有,并且单击也会有二次确认提示,而且删除图标是鼠标悬浮块之后出现。然后就是如果用户做了增删改的操作,都会展示一个徽标组件,标注用户修改过,这是因为这里会有数据缓存,用户如果做了改动,要给予用户一些反馈,并且刷新或者是关闭浏览器的时候要有所反馈。

以上是对所有的交互做了一个具体的分析,接下来我们来看本次实现所需要的技术栈。

所用到的技术栈

本次值班表的实现用到的技术栈也有不少,分别列出如下:

  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

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 包管理工具,读者可自行使用其它包管理工具初始化项目。

初始化项目完成之后,我们需要改造一下整个项目目录结构,如下所示:

duty-roster-directory.png

当然具体目录结构读者也可以自行分类管理,方便维护组织代码即可,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; // 更新后的参数
}

结束

本篇章我们分析了值班表的交互逻辑,以及一些准备工作,下一篇章我们将具体讲如何去实现整个值班表的交互逻辑,感谢大家观看,本篇章就到此结束,下篇文章写完再贴出源码。