React周视图组件封装
技术栈:React、antd
需求背景
使用周视图来显示广播信息与状态
组件特点
- 当多个时间块交叠时,并行显示。对于交叠时间块,最多显示3个,如果要显示全部交叠的时间块,可点击展开。
- 可对时间段精度进行扩展。当有多个时间段短的时间块相邻但不重叠时,方便看查。
- 支持跨天显示。对于跨天的广播,会将其分割。假设该广播跨了3天,那么就会根据日期分割成3个时间块,单独填充到对应的日期。
- 支持点击回到本周。
效果展示
实现过程
数据转换
本示例的原始数据如下:
{
"code": 0,
"description": "成功",
"data": {
"list": [{
"ebmid": "24300000000000103010101202409240001",
"name": "1122222",
"status": "3",
"start_time": "2024-09-24 16:30:39",
"end_time": "2024-09-24 16:34:32",
"msg_type": "1",
"covered_regions": "常德市",
"creator": "省平台"
}]
}
}
最后渲染到组件的数据如下,省略了一些无关属性方便理解:
{
"2024-09-24": [Array<TimeBlock>]
}
该转换过程给原始元素加了splited_start_time、splited_end_time、key三个属性,后者用于标识时间块的唯一性。
一般情况下,日期对应的值是时间块元素就可以了。但是因为存在时间段重叠的原因,就对元素进行了二次分组,将时间重叠的时间块分为一组。
{
"2024-09-24": [
[
{
"start_time": "2024-09-24 16:30:39",
"end_time": "2024-09-24 16:34:32",
"splited_start_time": "2024-09-24 16:30:39",
"splited_end_time": "2024-09-24 16:34:32",
"key": "4FFXJ2CG"
}
]
],
"2024-09-26": [
[
{
"start_time": "2024-09-26 01:16:09",
"end_time": "2024-09-26 02:20:02",
"splited_start_time": "2024-09-26 01:16:09",
"splited_end_time": "2024-09-26 02:20:02",
"key": "3RQOUQV1"
}
],
[
{
"start_time": "2024-09-26 03:07:22",
"end_time": "2024-09-26 12:11:15",
"splited_start_time": "2024-09-26 03:07:22",
"splited_end_time": "2024-09-26 12:11:15",
"key": "NAOO8UZD"
},
{
"start_time": "2024-09-26 04:17:15",
"end_time": "2024-09-26 13:21:08",
"splited_start_time": "2024-09-26 04:17:15",
"splited_end_time": "2024-09-26 13:21:08",
"key": "RI8IT06L"
},
{
"start_time": "2024-09-26 11:13:35",
"end_time": "2024-09-26 14:15:12",
"splited_start_time": "2024-09-26 11:13:35",
"splited_end_time": "2024-09-26 14:15:12",
"key": "P17N84F0"
}
]
]
}
数据转换思路:
- 首先将原始数据进行分割(针对跨天的情况),当元素跨3天,就分割成3个元素
- 对分割后的元素基于开始时间进行从早到晚排序
- 对排序后的元素进行按天分组
- 对按天分组内的元素进行二次分组,将时间段重叠的单独分成一组
组件文件结构
- index.js
- useWeek.js // 控制周切换相关方法
- Controler.js // 控制周切换
- TimeBlock.js // 时间块子组件
- Detail.js // 广播详情展示
- ColorTags.js // 颜色图标提示
- utils.js // 通用方法
- useExpandTime.js
- style.less
是有更优化的结构的,但是实现了就懒得优化了。
源码部分
index.js 入口文件
import React, { useEffect, useState } from 'react';
import useWeek from './useWeek';
import Controler from './Controler';
import './style.less';
import { formatData, hoursArray } from './utils';
import { Icon } from 'antd';
import { Ajax } from 'src/common';
import ColorTags from './ColorTags';
import useExpandTime from './useExpandTime';
import TimeBlock from './TimeBlock';
const BrCalendarView = () => {
const { weekInfo, prevWeek, nextWeek, resetToCurrentWeek } = useWeek();
const { handleExpand, cellProps } = useExpandTime();
const [activeBlock, setActiveBlock] = useState('');
const [data, setData] = useState([]);
const [expandDay, setExpandDay] = useState({
show: false,
day: {},
data: []
});
const openModal = (info) => {
setExpandDay({
show: true,
day: info.day,
data: info.data
});
};
const handleActive = (id) => {
setActiveBlock(id);
};
useEffect(() => {
/**
* 发送请求
*
* 入参:
* filter:{
* start_time: weekInfo.startDate.datetime,
* end_time: weekInfo.endDate.datetime
* }
*
* 重置状态
* setData(formatData(data.list));
*/
Ajax.ajax({
url: 'BrdCalendar',
showLoading: false,
data: {
filter: {
start_time: weekInfo.startDate.datetime,
end_time: weekInfo.endDate.datetime
}
},
success: (data) => {
setData(formatData(data.list));
}
});
}, [weekInfo.startDate.datetime, weekInfo.endDate.datetime]);
return (
<React.Fragment>
<div className="br-calendar-view">
<Controler prevWeek={prevWeek} weekInfo={weekInfo} resetToCurrentWeek={resetToCurrentWeek} nextWeek={nextWeek} />
<div className="br-calendar-view__content">
<ColorTags />
{/* 表格部分 */}
<div className="view-table">
{/* 头部 */}
<div className="view-table-header">
<div className="expand relative fr" style={{ width: '138px' }} onClick={handleExpand}>
<span style={{ marginRight: '8px' }}>时刻表(展开)</span>
</div>
{/* 根据天的展开与否显示不同组件 */}
{expandDay.show ? (
<div
className="fc relative expand"
style={{ flex: 1 }}
onClick={() => {
setExpandDay({
...expandDay,
show: false
});
}}
>
<div> {expandDay.day.day}</div>
<div>({expandDay.day.shortFormat})</div>
<Icon type="fullscreen-exit" className="right" title="返回" />
</div>
) : (
weekInfo.days.map((item) => {
const isExpand = data[item.date] && Math.max(...data[item.date].map((item) => item.length)) > 3;
return (
<div
className={`fc relative ${isExpand ? 'expand' : ''}`}
onClick={() => {
if (!isExpand) {
return;
}
openModal({
day: item,
data: data[item.date]
});
}}
>
<div> {item.day}</div>
<div>({item.shortFormat})</div>
{isExpand && <Icon type="fullscreen" className="right" title="更多" />}
</div>
);
})
)}
</div>
{/* 下方表格 */}
<div className="view-table-column">
{/* 时间段 */}
<div className="column" style={{ width: '138px' }}>
{hoursArray.map((item, index) => (
<div
className="cell"
style={{
...cellProps,
borderRight: '1px solid #eee',
// borderLeft: '1px solid #28568c',
...(index === 11
? {
borderBottomColor: 'rgba(104, 185, 255, 0.8)',
borderBottomStyle: 'solid'
}
: {})
}}
key={item.start}
>
{item.start}-{item.end}
</div>
))}
</div>
{/* 时间块 */}
{expandDay.show ? (
<div className="relative" style={{ flex: 1, height: '100%' }}>
{hoursArray.map((item) => (
<div className="cell" style={cellProps}></div>
))}
{expandDay.data.map((blocks) => {
let width = 100;
return blocks.map((item, index) => (
<TimeBlock
data={item}
width={width}
index={index}
key={item.uuid}
onMouseChange={handleActive}
isAvtive={activeBlock === item.ebmid}
/>
));
})}
</div>
) : (
weekInfo.days.map((item) => (
<div className="column relative">
{hoursArray.map((item, index) => (
<div
className="cell"
key={item.start}
style={{
...cellProps,
...(index === 11
? { borderBottomColor: 'rgba(104, 185, 255, 0.8)', borderBottomStyle: 'solid' }
: {})
}}
></div>
))}
{data[item.date] &&
data[item.date].map((blocks) => {
const length = blocks.length;
let width = Math.floor(100 / Math.min(length, 3));
return blocks
.slice(0, 3)
.map((item, index) => (
<TimeBlock
data={item}
width={width}
index={index}
unit="%"
key={item.uuid}
onMouseChange={handleActive}
isAvtive={activeBlock === item.ebmid}
/>
));
})}
</div>
))
)}
</div>
</div>
</div>
</div>
</React.Fragment>
);
};
export default BrCalendarView;
useWeek.js 控制周切换相关方法
import { useState, useCallback } from 'react';
import { formatDateTime, formatDate } from './utils';
// 获取本周的周一日期
function getMonday(date) {
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day; // 周一为0,周日为6
date.setDate(date.getDate() + diff);
date.setHours(0, 0, 0, 0);
return new Date(date);
}
// 获取本周的周日日期
function getSunday(date) {
const day = date.getDay();
const diff = day === 0 ? 0 : 7 - day; // 周一为0,周日为6
date.setDate(date.getDate() + diff);
date.setHours(23, 59, 59, 999);
return new Date(date);
}
// 获取星期名称
function getDayName(date) {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return days[date.getDay()];
}
// useWeek hook
function useWeek() {
const [startDate, setStartDate] = useState(() => getMonday(new Date()));
const [endDate, setEndDate] = useState(() => getSunday(new Date()));
const getWeekInfo = useCallback(() => {
const today = new Date();
// 周一到周日
const days = Array(7)
.fill()
.map((_, index) => {
const day = new Date(startDate);
day.setDate(startDate.getDate() + index);
const date = formatDate(day);
return {
date,
day: getDayName(day),
shortFormat: date.split('-').slice(1).join('-')
};
});
const weekInfo = {
today: {
date: formatDate(today),
day: getDayName(today)
},
startDate: {
date: formatDate(startDate),
day: getDayName(startDate),
datetime: formatDateTime(startDate)
},
endDate: {
date: formatDate(endDate),
day: getDayName(endDate),
datetime: formatDateTime(endDate)
},
days,
isCurrentWeek: days.map((item) => item.date).includes(formatDate(today))
};
return weekInfo;
}, [startDate, endDate]);
const prevWeek = useCallback(() => {
const newStartDate = new Date(startDate);
newStartDate.setDate(newStartDate.getDate() - 7);
setStartDate(getMonday(newStartDate));
setEndDate(getSunday(newStartDate));
}, [startDate]);
const nextWeek = useCallback(() => {
const newStartDate = new Date(startDate);
newStartDate.setDate(newStartDate.getDate() + 7);
setStartDate(getMonday(newStartDate));
setEndDate(getSunday(newStartDate));
}, [startDate]);
const resetToCurrentWeek = useCallback(() => {
setStartDate(getMonday(new Date()));
setEndDate(getSunday(new Date()));
}, []);
return { weekInfo: getWeekInfo(), prevWeek, nextWeek, resetToCurrentWeek };
}
export default useWeek;
Controler.js 周切换组件
import React from 'react';
import { Button } from 'antd';
const Controler = ({ prevWeek, weekInfo, resetToCurrentWeek, nextWeek }) => {
return (
<div className="br-calendar-view__header">
<Button onClick={prevWeek} type="primary">
上一周
</Button>
<div className="current-week-wrapper fc">
<div className={`week-info ${weekInfo.isCurrentWeek ? 'active' : ''}`}>
{weekInfo.startDate.date} ~{weekInfo.endDate.date}
</div>
{!weekInfo.isCurrentWeek && (
<a href="javascript:void 0" onClick={resetToCurrentWeek} style={{ fontSize: '1.2em' }}>
回到本周
</a>
)}
</div>
<Button onClick={nextWeek} type="primary">
下一周
</Button>
</div>
);
};
export default Controler;
TimeBlock.js 时间块组件
import { Tooltip } from 'antd';
import Detail from './Detail';
import { getBlockProps, colorTags } from './utils';
import React from 'react';
const TimeBlock = ({ data, width, index = 0, unit = 'px', onMouseChange, isAvtive = false }) => (
<Tooltip placement="rightTop" title={<Detail data={data} />} overlayClassName="expandwidth">
<div
onMouseEnter={(e) => {
e.stopPropagation();
if (onMouseChange) {
onMouseChange(data.ebmid);
}
}}
onMouseLeave={(e) => {
e.stopPropagation();
if (onMouseChange) {
onMouseChange('');
}
}}
style={{
width: `${width - 2}${unit}`,
left: `${width * index + 1}${unit}`,
...getBlockProps(data.splited_start_time, data.splited_end_time),
background: isAvtive ? `rgb(255, 210, 95,1)` : colorTags[data.status].color
}}
className="block"
>
{data.name}
</div>
</Tooltip>
);
export default TimeBlock;
Detail.js 详情组件
import { Row, Col } from 'antd';
import { colorTags } from './utils';
import { Opts } from 'src/common';
import React from 'react';
const Detail = ({ data }) => {
const column = [
{
label: '广播名称',
dataKey: 'name'
},
{
label: 'Ebmid',
dataKey: 'ebmid'
},
{
label: '广播类型',
dataKey: 'msg_type',
render: (v) => Opts.getTxt(Opts.g_superiorEbmClass, v)
},
{
label: '开始时间',
dataKey: 'start_time',
render: (v) => {
const [date, time] = v.split(' ');
return (
<span>
<span>{date}</span>
<span style={{ marginLeft: '4px', color: 'rgb(255, 210, 95,1)' }}>{time}</span>
</span>
);
}
},
{
label: '结束时间',
dataKey: 'end_time',
render: (v) => {
const [date, time] = v.split(' ');
return (
<span>
<span>{date}</span>
<span style={{ marginLeft: '4px', color: 'rgb(255, 210, 95,1)' }}>{time}</span>
</span>
);
}
},
{
label: '播发状态',
dataKey: 'status',
render: (v) => <span style={{ color: colorTags[v].color }}>{colorTags[v].label}</span>
},
{
label: '覆盖区域',
dataKey: 'covered_regions'
},
{
label: '创建人',
dataKey: 'creator'
}
];
return (
<div style={{ width: '100%' }}>
{column.map((item) => (
<Row>
<Col span={6}>{item.label}:</Col>
<Col span={18}>{item.render ? item.render(data[item.dataKey], data) : data[item.dataKey]}</Col>
</Row>
))}
</div>
);
};
export default Detail;
ColorTags.js 图例
import { colorTags } from './utils';
import React from 'react';
const ColorTags = () => {
return (
<div className="color-tags">
{Object.values(colorTags).map((item) => (
<div>
<div style={{ width: '28px', height: '16px', background: item.color, marginRight: '4px' }}></div>
<div>{item.label}</div>
</div>
))}
</div>
);
};
export default ColorTags;
useExpandTime.js 控制时刻表的展开
import { cellHeight } from './utils';
import { useState } from 'react';
const type = ['mini', 'medium', 'large'];
const useExpandTime = () => {
const [expand, setExpand] = useState(0);
const handleExpand = () => {
if (expand === 2) {
setExpand(0);
} else {
setExpand(expand + 1);
}
};
return {
expand,
handleExpand,
cellProps: cellHeight[type[expand]]
};
};
export default useExpandTime;
utils.js 其他方法
import { Util } from 'src/common';
export function formatDateTime(date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 格式化日期
export function formatDate(date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 常量:一天的秒数
export const MinutesForDay = 1440;
// 计算 时间块顶部到零点相隔的时间 以及 时间块所占用的时间 ,单位分钟
function calculateTimeDifferences(startTime, endTime) {
// 将时间字符串转换为Date对象
const start = new Date(startTime);
const end = new Date(endTime);
// 创建当天0点的Date对象
const midnight = new Date(startTime);
midnight.setHours(0, 0, 0, 0);
// 计算从0点到start_time的间隔(分钟)
const diffFromMidnightToStart = (start - midnight) / (1000 * 60);
// 计算从start_time到end_time的间隔(分钟)
const diffFromStartToEnd = (end - start) / (1000 * 60);
// 将结果四舍五入并转换为整数
const minutesFromMidnightToStart = Math.round(diffFromMidnightToStart);
const minutesFromStartToEnd = Math.round(diffFromStartToEnd);
return {
fromMidnightToStart: minutesFromMidnightToStart,
fromStartToEnd: minutesFromStartToEnd
};
}
// 根据起止时间拿到 位置 和 高度 属性
export const getBlockProps = (startTime, endTime) => {
const { fromMidnightToStart, fromStartToEnd } = calculateTimeDifferences(startTime, endTime);
const top = ((fromMidnightToStart / MinutesForDay) * 100).toFixed(2);
const height = ((fromStartToEnd / MinutesForDay) * 100).toFixed(2);
return {
top: `${top}%`,
height: `${height}%`
};
};
// 处理重叠:对存在时间段重叠的广播进行分组
export function groupOverlapping(items, startkey, endkey) {
// 初始化分组结果
const groups = [];
let currentGroup = [];
for (let item of items) {
// 如果当前组为空,则有重叠
if (currentGroup.length === 0) {
currentGroup.push(item);
continue;
}
// 当前时间段的开始时间小于数组内最晚的结束时间,则有重叠
const lastTime = Math.max(...currentGroup.map((item) => new Date(item[endkey]).getTime()));
if (new Date(item[startkey]).getTime() < lastTime) {
currentGroup.push(item);
continue;
}
// 否则,当前时间段与当前组没有重叠,开始新的组
groups.push(currentGroup);
currentGroup = [item];
}
// 将最后一组添加到结果中
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
return groups;
}
function splitInterval(interval) {
const intervals = [];
let currentStart = new Date(interval.start_time);
let currentEnd = new Date(interval.end_time);
// 循环直到当前开始时间超过结束时间
while (currentStart < currentEnd) {
let endOfDay = new Date(currentStart);
endOfDay.setHours(23, 59, 59, 999);
// 如果结束时间早于当天的23:59:59,则使用结束时间
if (endOfDay > currentEnd) {
endOfDay = new Date(currentEnd);
}
intervals.push({
...interval,
splited_start_time: formatDateTime(currentStart),
splited_end_time: formatDateTime(endOfDay),
key: Util.getUUID()
});
// 如果当前时间段的结束时间等于原始结束时间,结束循环
if (endOfDay.getTime() === currentEnd.getTime()) {
break;
}
// 设置下一个时间段的开始时间
currentStart = new Date(endOfDay);
currentStart.setHours(0, 0, 0, 0);
currentStart.setDate(currentStart.getDate() + 1);
}
return intervals;
}
// 处理跨天
export function splitIntervals(inputIntervals) {
const allIntervals = [];
inputIntervals.forEach((interval) => {
allIntervals.push(...splitInterval(interval));
});
return allIntervals;
}
// 根据日期分组
const groupByDay = (intervals, comparekey = 'start_time') => {
const groups = {};
intervals.forEach((interval) => {
// 获取开始日期的年月日作为键
const startKey = interval[comparekey].split(' ')[0];
// 如果该日期还没有分组,则创建一个新组
if (!groups[startKey]) {
groups[startKey] = [];
}
// 将时间段添加到对应的日期组中
groups[startKey].push(interval);
});
// 将分组对象转换为数组
return groups;
};
// 格式化原始数据
export const formatData = (data) => {
// 1. 分割
const allSplitedData = splitIntervals(data);
// 2. 排序
allSplitedData.sort((a, b) => new Date(a.splited_start_time).getTime() - new Date(b.splited_start_time).getTime());
// 3. 按天分组
const groups = groupByDay(allSplitedData, 'splited_start_time');
// 4. 重组
Object.keys(groups).forEach((key) => {
groups[key] = groupOverlapping(groups[key], 'splited_start_time', 'splited_end_time');
});
return groups;
};
export const colorTags = {
3: {
label: '已播发',
color: 'rgba(193,193,193, 1)'
},
2: {
label: '正在播发',
color: '#5ca2fb'
},
1: {
label: '等待播发',
color: '#5dd560'
}
};
export const hoursArray = [
{ start: '00:00', end: '01:00' },
{ start: '01:00', end: '02:00' },
{ start: '02:00', end: '03:00' },
{ start: '03:00', end: '04:00' },
{ start: '04:00', end: '05:00' },
{ start: '05:00', end: '06:00' },
{ start: '06:00', end: '07:00' },
{ start: '07:00', end: '08:00' },
{ start: '08:00', end: '09:00' },
{ start: '09:00', end: '10:00' },
{ start: '10:00', end: '11:00' },
{ start: '11:00', end: '12:00' },
{ start: '12:00', end: '13:00' },
{ start: '13:00', end: '14:00' },
{ start: '14:00', end: '15:00' },
{ start: '15:00', end: '16:00' },
{ start: '16:00', end: '17:00' },
{ start: '17:00', end: '18:00' },
{ start: '18:00', end: '19:00' },
{ start: '19:00', end: '20:00' },
{ start: '20:00', end: '21:00' },
{ start: '21:00', end: '22:00' },
{ start: '22:00', end: '23:00' },
{ start: '23:00', end: '24:00' }
];
export const cellHeight = {
mini: {
height: '28px',
lineHeight: '28px'
},
medium: {
height: '64px',
lineHeight: '64px'
},
large: {
height: '300px',
lineHeight: '300px'
}
};
style.less
.expandwidth .ant-tooltip-inner {
min-width: 370px;
}
// 通用
.color-tags {
display: flex;
justify-content: end;
margin: 4px 0;
& > div {
display: flex;
align-items: center;
margin-right: 6px;
}
}
.fc {
display: flex;
flex-direction: column;
align-items: center;
}
.fr {
display: flex;
align-items: center;
justify-content: center;
}
div {
box-sizing: border-box;
}
.relative {
position: relative;
}
.view-table-header {
display: flex;
background: #6fa9ec;
color: white;
font-weight: bold;
& > div {
padding: 4px;
border-right: 1px solid white;
cursor: default;
}
}
.view-table-column {
display: flex;
margin-top: 2px;
max-height: 680px;
overflow: auto;
&::-webkit-scrollbar {
width: 10px; /* 设置横向滚动条的高度 */
height: 10px;
}
/* 滚动条轨道 */
&::-webkit-scrollbar-track {
background: #f0f0f0; /* 轨道背景颜色 */
border-top: 1px solid #ccc; /* 轨道与内容的分隔线 */
}
/* 滚动条滑块 */
&::-webkit-scrollbar-thumb {
background: #ccc; /* 滑块背景颜色 */
border-top: 1px solid #ccc; /* 滑块与轨道的分隔线 */
}
// 通用单元格样式
.column {
border-right: 1px dashed #eee;
height: 100%;
}
.cell {
text-align: center;
font-size: 1.2em;
border-bottom: 1px dashed #eee;
&:nth-child(2n + 1) {
background: #f8fcff;
}
}
// 时间块
.block {
padding: 2px 4px;
border-radius: 4px;
background: #9dc2ec;
position: absolute;
color: white;
cursor: pointer;
min-height: 24px;
border: 1px solid white;
overflow: hidden;
}
}
.br-calendar-view {
.br-calendar-view__header {
display: flex;
justify-content: space-between;
.current-week-wrapper {
.week-info {
font-size: 1.4em;
&.active {
color: dodgerblue;
}
}
}
}
.br-calendar-view__content {
.view-table {
.view-table-header {
& > div {
width: 14.28%;
&.expand {
cursor: pointer;
&:hover {
background-color: #28568c;
font-weight: bolder;
}
}
i.right {
position: absolute;
right: 10px;
font-size: 2em;
top: 11px;
}
}
}
.view-table-column {
.column {
width: 14.28%;
}
}
}
}
}