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; // 更新后的参数
}
结束
本篇章我们分析了值班表的交互逻辑,以及一些准备工作,下一篇章我们将具体讲如何去实现整个值班表的交互逻辑,感谢大家观看,本篇章就到此结束,下篇文章写完再贴出源码。