技术栈:React、antd
需求背景
使用周视图来显示广播信息与状态
组件特点
- 当多个广播时间段交叠时,并行显示。对于交叠广播,最多显示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": "省平台"
}]
}
}
组件文件结构
- 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 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));
*/
}, [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}
onm ouseChange={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}
onm ouseChange={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', onm ouseChange, isAvtive = false }) => (
<Tooltip placement="rightTop" title={<Detail data={data} />} overlayClassName="expandwidth">
<div
onm ouseEnter={(e) => {
e.stopPropagation();
if (onMouseChange) {
onm ouseChange(data.ebmid);
}
}}
onm ouseLeave={(e) => {
e.stopPropagation();
if (onMouseChange) {
onm ouseChange('');
}
}}
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'; // 主要使用了一个生成uuid的方法,可以自行封装
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 = 'start_time', endkey = 'end_time') {
// items.sort((a, b) => a[startkey] - b[startkey]);
// 初始化分组结果
const groups = [];
let currentGroup = [];
for (let item of items) {
// 如果当前组为空,或者当前时间段的开始时间小于等于当前组最后一个时间段的结束时间,则有重叠
if (currentGroup.length === 0 || currentGroup.map((item) => item[endkey]).some((end) => item[startkey] < end)) {
currentGroup.push(item);
} else {
// 否则,当前时间段与当前组没有重叠,开始新的组
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) => a.splited_start_time - b.splited_start_time);
// 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%;
}
}
}
}
}
标签:00,const,item,end,视图,React,start,date,封装
From: https://www.cnblogs.com/sanhuamao/p/18440328