React周檢視元件封裝

sanhuamao發表於2024-09-29

技術棧:React、antd

需求背景

使用周檢視來顯示廣播資訊與狀態

元件特點

  1. 當多個廣播時間段交疊時,並行顯示。對於交疊廣播,最多顯示3個,如果要顯示全部交疊的廣播,可點選展開。
  2. 可對時間段精度進行擴充套件。當多個時間短但不重疊的廣播放在一起時,更方便看。
  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}
                                    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'; // 主要使用了一個生成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%;
            }
         }
      }
   }
}

相關文章