react实现日历拖拽效果

功能包括(动图在最下面)

1、日历的时间范围支持动态传入

2、支持单选和多选模式

3、支持农历、阴历信息展示

4、支持节假日显示

5、支持单次点击选中,反选等功能

6、支持拖拽选中,再次拖拽取消选中

CalendarSelect.tsx

TypeScript 复制代码
import React, { useState, useRef, useEffect, useCallback } from 'react';
import Lunar from 'lunar-javascript';
import styles from './index.module.less';
import dayjs from 'dayjs';
import { Checkbox, Radio } from 'antd';

interface IProps {
  /**
   * 是否是单选
   */
  isRadio?: boolean;
  /**
   * 时间戳
   */
  value?: IRangeTime[];
  /**
   * 变化事件
   */
  onSelected?: (selectedTimeRanges: IRangeTime[]) => void;
  /**
   * 统计周期
   */
  timeRange: number[];
}
export interface IRangeTime {
  /**
   * 起始时间
   */
  holidayDateStart: number;
  /**
   * 结束时间
   */
  holidayDateEnd: number;
}
interface ICalendarData {
  /**
   * 日期
   */
  date?: number | null;
  index?: number;
  key?: string;
  /**
   * 是否是占位符
   */
  isPlaceholder?: boolean;
  /**
   * 阴历日期
   */
  solarDay?: string;
  /**
   * 农历日期
   */
  lunarDay?: string;
  /**
   * 节气
   */
  solarTerm?: string;
  /**
   * 节假日
   */
  holiday?: string[];
}

interface IPosition {
  top?: number;
  bottom?: number;
  left?: number;
  right?: number;
  width?: number;
  height?: number;
}
const CalendarRangeSelector = (props: IProps) => {
  const { isRadio = true, value, onSelected, timeRange } = props;
  // 日历数据(包含日期、是否占位、农历、节气、节假日)
  const [days, setDays] = useState<ICalendarData[]>([]);
  // 缓存的单元格位置(预计算,避免动态查询DOM)
  const cellPositionsRef = useRef<(IPosition | null)[]>([]);
  // 选中的日期区间(双重数组:[{start, end}, ...],单选时为[{start, end}])
  const [selectedRanges, setSelectedRanges] = useState<
    { start: number; end: number }[]
  >([]);
  const [selectedTimeRanges, setSelectedTimeRanges] = useState<IRangeTime[]>(
    [],
  );

  // 拖拽状态
  const [isDragging, setIsDragging] = useState(false);
  // 拖拽起始索引
  const [dragStartIndex, setDragStartIndex] = useState<number | undefined>();
  // 上一次有效选中的索引(防重复触发)
  const [lastValidIndex, setLastValidIndex] = useState<number | undefined>();
  // 记录现在mover在托拽的index集合
  const [currentMoveIndexArr, setCurrentMoveIndexArr] = useState<number[]>([]);
  // 判断是不是单次点击
  const [isSingleClick, setIsSingleClick] = useState<boolean>(false);

  // 日历容器引用
  const calendarRef = useRef<HTMLDivElement>(null);

  const DATA_WEEK = ['一', '二', '三', '四', '五', '六', '日'];

  // 初始化
  useEffect(() => {
    // 依据当前选中的时间来决定显示几月的日期
    generateCalendarByRange(timeRange);
    // 赋值当前选中的日期
    setSelectedTimeRanges(value ?? []);
  }, [value, timeRange]);

  /**
   * 生成日历数据并预计算单元格位置
   */
  const generateCalendarByRange = (timeRange: number[]) => {
    // 解析时间戳范围 [startTimestamp, endTimestamp]
    const [startTimestamp, endTimestamp] = timeRange;

    // 验证时间戳有效性
    if (!startTimestamp || !endTimestamp || startTimestamp > endTimestamp) {
      console.error('无效的时间戳范围');
      return [];
    }

    // 转换为dayjs对象(基于时间戳)
    const startDate = dayjs(startTimestamp);
    const endDate = dayjs(endTimestamp);

    const allCalendarDays: ICalendarData[] = [];
    let currentIndex = 0; // 全局索引:包含所有占位符和实际日期
    let currentDate = startDate.clone();

    // 1. 添加开始日期所在周的前置占位符(计入全局索引)
    const firstDayOfWeek = currentDate.day() || 7; // 1=周一,7=周日
    const daysToPrepend = firstDayOfWeek - 1;

    for (let i = daysToPrepend; i > 0; i--) {
      const placeholderDate = currentDate.clone().subtract(i, 'day');
      allCalendarDays.push({
        date: null,
        index: currentIndex, // 占位符索引(全局递增)
        isPlaceholder: true,
        solarDay: '',
        lunarDay: '',
        solarTerm: '',
        holiday: [],
        key: `pre-${placeholderDate.valueOf()}`,
      });
      currentIndex++; // 索引递增
    }

    // 初始化选择范围索引(与value长度匹配)
    let selectRangeIndex: { start?: number; end?: number }[] =
      value?.map(() => ({ start: -1, end: -1 })) || [];

    // 2. 添加范围内的实际日期(计入全局索引)
    currentDate = startDate.clone(); // 重置为起始日期
    while (currentDate.isBefore(endDate) || currentDate.isSame(endDate)) {
      const currentTime = currentDate.valueOf();
      const date = currentDate.toDate();
      const lunar = Lunar.Solar.fromDate(date).getLunar();

      // 推入实际日期(包含全局索引)
      allCalendarDays.push({
        date: currentTime, // 时间戳
        index: currentIndex, // 全局索引(包含占位符)
        isPlaceholder: false,
        solarDay: `${String(currentDate.month() + 1).padStart(2, '0')}-${String(currentDate.date()).padStart(2, '0')}`,
        lunarDay: lunar.getDayInChinese(),
        solarTerm: lunar.getJieQi() || '',
        holiday: [
          ...lunar.getFestivals(),
          ...Lunar.Solar.fromDate(date).getFestivals(),
        ],
        key: currentTime.toString(),
      });

      // 处理选择范围索引计算
      if (isRadio) {
        // 单选逻辑:匹配开始时间
        if (currentTime === value?.[0]?.holidayDateStart) {
          selectRangeIndex = [{ start: currentIndex, end: currentIndex }];
        }
      } else {
        // 多选逻辑:遍历value匹配开始/结束时间(修复变量名冲突)
        value?.forEach((item, itemIndex) => {
          // 匹配开始时间
          if (item?.holidayDateStart === currentTime) {
            selectRangeIndex[itemIndex] = {
              ...selectRangeIndex[itemIndex],
              start: currentIndex,
            };
          }
          // 匹配结束时间
          if (item?.holidayDateEnd === currentTime) {
            selectRangeIndex[itemIndex] = {
              ...selectRangeIndex[itemIndex],
              end: currentIndex,
            };
          }
        });
      }

      currentDate = currentDate.add(1, 'day');
      currentIndex++; // 全局索引递增
    }

    // 3. 添加添加结束日期所在周的后置占位符(计入全局索引)
    const lastDayOfWeek = endDate.day() || 7;
    const daysToAppend = 7 - lastDayOfWeek;

    for (let i = 1; i <= daysToAppend; i++) {
      const placeholderDate = endDate.clone().add(i, 'day');
      allCalendarDays.push({
        date: null,
        index: currentIndex, // 后置占位符索引
        isPlaceholder: true,
        solarDay: '',
        lunarDay: '',
        solarTerm: '',
        holiday: [],
        key: `post-${placeholderDate.valueOf()}`,
      });
      currentIndex++; // 索引递增
    }

    // @ts-ignore
    setSelectedRanges(selectRangeIndex);
    setDays(allCalendarDays);
    precomputeCellPositions(allCalendarDays);
  };

  /**
   * 预计算所有非占位符单元格的实际位置(基于固定样式尺寸)
   */
  const precomputeCellPositions = (calendarDays: ICalendarData[]) => {
    // 组件重新加载时,有延迟
    setTimeout(() => {
      if (!calendarRef.current) return;
      const containerWidth = calendarRef.current.clientWidth;
      if (containerWidth === 0) return; // 避免容器宽度为0时计算错误

      const cellWidth = containerWidth / 7; // 7列等宽(与样式一致)
      const cellHeight = 70; // 固定行高(与样式一致)

      const positions: (IPosition | null)[] = [];
      let colIndex = 0;
      let rowIndex = 0;

      calendarDays.forEach((day: ICalendarData, index: number) => {
        if (day.isPlaceholder) {
          positions[index] = null; // 占位符位置设为null
          colIndex++;
          if (colIndex >= 7) {
            rowIndex++;
            colIndex = 0;
          }
          return;
        }

        // 计算单元格左上角坐标(相对于容器)
        const left = colIndex * cellWidth;
        const top = rowIndex * cellHeight;
        positions[index] = {
          left,
          top,
          right: left + cellWidth,
          bottom: top + cellHeight,
          width: cellWidth,
          height: cellHeight,
        };
        // 更新行列索引
        colIndex++;
        if (colIndex >= 7) {
          rowIndex++;
          colIndex = 0;
        }
      });

      cellPositionsRef.current = positions;
    }, 0);
  };

  /**
   * 高精度计算鼠标位置对应的日期索引(基于预计算的位置)
   */
  const getIndexFromMousePosition = useCallback(
    (e: MouseEvent) => {
      if (!calendarRef.current || cellPositionsRef.current.length === 0)
        return null;

      const rect = calendarRef.current.getBoundingClientRect();
      const clientX = e.clientX - rect.left; // 鼠标相对于容器左侧的X坐标,比较准确
      const clientY = e.clientY - rect.top; // 鼠标相对于容器顶部的Y坐标

      // 遍历预计算的非占位符位置
      for (let i = 0; i < cellPositionsRef.current.length; i++) {
        const pos = cellPositionsRef.current[i];
        if (!pos) continue; // 跳过占位符

        // 精确判断鼠标是否在单元格内(包含边框1px误差)
        const isInside =
          clientX >= (pos.left ?? 0) + 2 && // 左边界容错(-2px)
          clientX <= (pos.right ?? 0) - 2 && // 右边界容错(+2px)
          clientY >= (pos.top ?? 0) + 2 && // 上边界容错(-2px)
          clientY <= (pos.bottom ?? 0) - 2; // 下边界容错(+2px)
        if (isInside) return i; // 找到目标单元格索引
      }

      return null; // 未找到有效单元格
    },
    [days], // 依赖days,确保数据变化时重新计算
  );

  /**
   * 合并相邻或重叠的区间(通用逻辑)
   * @param ranges 待合并的区间数组
   * @returns 合并后的区间数组
   */
  const mergeRanges = (
    ranges: Array<{ start: number; end: number }>,
  ): Array<{ start: number; end: number }> => {
    if (ranges.length === 0) return [];
    // 按起始点排序
    const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
    const merged: Array<{ start: number; end: number }> = sortedRanges[0]
      ? [sortedRanges[0]]
      : [];
    for (let i = 1; i < sortedRanges.length; i++) {
      const current = sortedRanges[i];
      const last = merged[merged.length - 1];
      if (current && last) {
        if (current.start <= last.end + 1) {
          // 合并相邻或重叠区间
          merged[merged.length - 1] = {
            start: last.start,
            end: Math.max(last.end, current.end),
          };
        } else {
          merged.push(current);
        }
      }
    }
    // 将下标转换成时间戳
    handleTimeRange(merged);
    return merged;
  };

  /**
   * 拆分包含目标索引的区间(通用逻辑)
   * @param ranges 原区间数组
   * @param targetIndex 要取消的目标索引
   * @returns 拆分后的区间数组(包含未拆分的无关区间和拆分后的子区间)
   */
  const splitRanges = (
    ranges: Array<{ start: number; end: number }>,
    targetIndex: number,
  ): Array<{ start: number; end: number }> => {
    return ranges.flatMap(range => {
      // 情况1:当前索引不在区间内 → 保留原区间
      if (targetIndex < range.start || targetIndex > range.end) return [range];
      // 情况2:区间仅有一个点 → 取消后移除
      if (range.start === range.end) return [];
      // 情况3:取消的是起点 → 缩短为 [start+1, end]
      if (targetIndex === range.start) {
        return range.start + 1 <= range.end
          ? [{ start: range.start + 1, end: range.end }]
          : [];
      }
      // 情况4:取消的是终点 → 缩短为 [start, end-1]
      if (targetIndex === range.end) {
        return range.end - 1 >= range.start
          ? [{ start: range.start, end: range.end - 1 }]
          : [];
      }
      // 情况5:取消的是中间点 → 拆分为 [start, index-1] 和 [index+1, end]
      return [
        { start: range.start, end: targetIndex - 1 },
        { start: targetIndex + 1, end: range.end },
      ];
    });
  };

  /**
   * 切换单个日期的选中状态(核心逻辑:单选/多选模式独立处理)
   */
  const toggleSingleDate = (index: number) => {
    setSelectedRanges(prev => {
      if (!isRadio) {
        // -------------------- 多选模式逻辑 --------------------
        const isContained = prev.some(
          range => index >= range.start && index <= range.end,
        );
        if (isContained) {
          // 步骤1:过滤无关区间 + 拆分包含目标索引的区间
          const filteredRanges = prev.filter(
            range => !(index >= range.start && index <= range.end),
          );
          const splitRangesResult = splitRanges(prev, index); // 使用公共拆分函数
          // 步骤2:合并所有相关区间(过滤后的无关区间 + 拆分后的子区间)
          const allRanges = [...filteredRanges, ...splitRangesResult];
          handleTimeRange(allRanges);
          return mergeRanges(allRanges); // 使用公共合并函数
        } else {
          // 步骤1:添加当前索引为单元素区间
          const newRanges = [...prev, { start: index, end: index }];
          handleTimeRange(newRanges);
          return mergeRanges(newRanges); // 使用公共合并函数
        }
      } else {
        // -------------------- 单选模式逻辑 --------------------
        // 检查当前索引是否已被选中(存在单元素区间)
        const isContained = prev.some(
          range => range.start === index && range.end === index,
        );
        const radioSelectRang = isContained
          ? []
          : [{ start: index, end: index }];

        // 更新时间范围(根据实际需求调整)
        if (radioSelectRang.length === 0) {
          setSelectedTimeRanges([]);
        } else {
          const [selectedRange] = radioSelectRang;
          if (selectedRange) {
            setSelectedTimeRanges([
              {
                holidayDateStart: dayjs(
                  days[selectedRange?.start]?.date,
                ).valueOf(),
                holidayDateEnd: dayjs(days[selectedRange?.end]?.date).valueOf(),
              },
            ]);
          }
        }
        return radioSelectRang; // 单选模式强制仅保留当前选中区间
      }
    });
  };

  /**
   * 处理鼠标按下(开始拖拽,单选模式直接选中,多选模式准备拖拽)
   */
  const handleMouseDown = (
    e: React.MouseEvent<HTMLDivElement, MouseEvent>,
    index: number,
  ) => {
    if (days[index]?.isPlaceholder) return;
    e.preventDefault(); // 防止文本选中
    setDragStartIndex(index);
    setLastValidIndex(index); // 记录上一次有效索引
    setIsDragging(true);
  };

  /**
   * 处理鼠标移动(多选模式:扩展选中范围,保留已有选择)
   */
  const handleMouseMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
    if (!isDragging) return;
    setIsSingleClick(false);
    const currentIndex = getIndexFromMousePosition(e.nativeEvent);
    if (currentIndex === null) return;

    // 生成从dragStartIndex到currentIndex的连续索引范围
    const start = Math.min(dragStartIndex ?? 0, currentIndex);
    const end = Math.max(dragStartIndex ?? 0, currentIndex);

    // 使用Array.from更简洁地生成索引数组
    const selectedIndices = Array.from(
      { length: end - start + 1 },
      (_, i) => start + i,
    ).filter(index => !days[index]?.isPlaceholder); // 过滤占位符

    setCurrentMoveIndexArr(selectedIndices);
  };

  const handleMouseLeave = () => {
    if (isDragging) {
      // 鼠标移出边界时,强制结束拖拽
      setIsDragging(false);
      setCurrentMoveIndexArr([]);
      setLastValidIndex(undefined);
      setDragStartIndex(undefined);
      setIsSingleClick(false);
    }
  };

  /**
   * 处理鼠标松开(结束拖拽)
   */
  const handleMouseUp = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
    if (!isDragging || isRadio) return;
    setIsDragging(false);
    const finallyIndex = getIndexFromMousePosition(e.nativeEvent);
    // 仅当新索引有效且与上一次有效索引不同时才更新
    if (finallyIndex === null || finallyIndex === lastValidIndex) return;

    // 计算当前拖拽范围(start<=end)
    const [start, end] = [
      Math.min(dragStartIndex ?? 0, finallyIndex),
      Math.max(dragStartIndex ?? 0, finallyIndex),
    ];
    // 生成拖拽范围内的有效索引
    const validIndices = Array.from(
      { length: end - start + 1 },
      (_, i) => start + i,
    ).filter(index => !days[index]?.isPlaceholder);

    // 复制当前选中区间
    const newRanges = [...selectedRanges];

    // 处理拖拽选中的每个索引
    validIndices.forEach(i => {
      // 查找包含当前索引的区间
      const coveredRangeIndex = newRanges.findIndex(
        range => i >= range.start && i <= range.end,
      );

      if (coveredRangeIndex !== -1) {
        const coveredRange = newRanges[coveredRangeIndex];

        // 情况1:完全覆盖整个区间
        if (coveredRange?.start === i && coveredRange?.end === i) {
          newRanges.splice(coveredRangeIndex, 1); // 移除整个区间
        }
        // 情况2:覆盖区间的开始部分
        else if (coveredRange?.start === i) {
          newRanges[coveredRangeIndex] = {
            start: i + 1,
            end: coveredRange.end,
          };
        }
        // 情况3:覆盖区间的结束部分
        else if (coveredRange?.end === i) {
          newRanges[coveredRangeIndex] = {
            start: coveredRange?.start,
            end: i - 1,
          };
        }
        // 情况4:覆盖区间的中间部分
        else {
          // 拆分为两个区间
          coveredRange &&
            newRanges.splice(
              coveredRangeIndex,
              1,
              { start: coveredRange?.start, end: i - 1 },
              { start: i + 1, end: coveredRange?.end },
            );
        }
      } else {
        // 未覆盖任何区间,添加为新的单元素区间
        newRanges.push({ start: i, end: i });
      }
    });

    // 合并相邻区间(保持原有逻辑不变)
    const mergedRanges: { start: number; end: number }[] = [];
    newRanges.sort((a, b) => a.start - b.start);
    newRanges.forEach(range => {
      if (mergedRanges.length === 0) {
        mergedRanges.push(range);
      } else {
        const last = mergedRanges[mergedRanges.length - 1];
        if (last) {
          if (range.start <= last?.end + 1) {
            mergedRanges[mergedRanges.length - 1] = {
              start: last?.start,
              end: Math.max(last?.end, range.end),
            };
          } else {
            mergedRanges.push(range);
          }
        }
      }
    });
    setSelectedRanges(mergedRanges);
    handleTimeRange(mergedRanges);
    setLastValidIndex(finallyIndex); // 更新上一次有效索引
    setCurrentMoveIndexArr([]);
  };

  const handleTimeRange = (indexRange: { start: number; end: number }[]) => {
    // 将下标映射成时间戳
    const _timeRange = indexRange.map(item => ({
      holidayDateStart: dayjs(days[item.start]?.date).valueOf(),
      holidayDateEnd: dayjs(days[item.end]?.date).valueOf(),
    }));
    setSelectedTimeRanges(_timeRange);
  };

  useEffect(() => {
    onSelected?.(selectedTimeRanges);
  }, [selectedTimeRanges, value]);

  return (
    <div className={styles['calendar-container']}>
      {/* 日历头部(周几) */}
      <div className={styles['header-row']}>
        {DATA_WEEK.map(day => (
          <div key={day} className={styles['header-cell']}>
            {day}
          </div>
        ))}
      </div>

      {/* 日历主体(日期网格) */}
      <div
        className={styles['calendar-body']}
        ref={calendarRef}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseLeave}
      >
        <div className={styles['grid']}>
          {days.map((day, index) => {
            const isSelected = !isRadio
              ? selectedRanges.some(
                  range => index >= range.start && index <= range.end,
                )
              : selectedRanges.length === 1 &&
                selectedRanges[0]?.start === index &&
                selectedRanges[0]?.end === index;

            return (
              <div
                key={day.key}
                className={`${styles['cell']}
                                            ${day.isPlaceholder ? styles['placeholder'] : ''}
                                            ${isSelected ? styles['selected'] : ''}
                                            ${currentMoveIndexArr?.includes(index) && isDragging && !isSingleClick ? styles['selected'] : ''}`}
                style={{
                  cursor: !isRadio ? 'grab' : 'pointer',
                  userSelect: 'none',
                }}
                // 复选框时候才会涉及托拽
                onMouseDown={e => {
                  if (day.isPlaceholder) return;
                  !isRadio && handleMouseDown(e, index);
                }}
                onClick={() => {
                  if (day.isPlaceholder) return;
                  setIsSingleClick(true);
                  toggleSingleDate(index);
                }}
              >
                {!day.isPlaceholder && (
                  <div className={styles['content']}>
                    {/* 阳历日期 */}
                    <div className={styles['solar-day']}>{day.solarDay}</div>

                    {/* 阴历/节气/节假日 */}
                    <>
                      {day.solarTerm ? (
                        <span className={styles['solar-term']}>
                          {day.solarTerm}
                        </span>
                      ) : (
                        <>
                          {day.holiday?.length ? (
                            <span
                              className={styles['holiday-tag']}
                              title={day.holiday.join('、')}
                            >
                              {day.holiday.join('、')}
                            </span>
                          ) : (
                            <span className={styles['solar-term']}>
                              {day.lunarDay}
                            </span>
                          )}
                        </>
                      )}
                    </>

                    {/* 动态渲染选择控件 */}
                    <div className={styles['selector']}>
                      {isRadio ? (
                        <Radio
                          name="selectedDate"
                          checked={isSelected}
                          className={'mt-3 mr-0'}
                        />
                      ) : (
                        <Checkbox checked={isSelected} className={'mt-3'} />
                      )}
                    </div>
                  </div>
                )}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default CalendarRangeSelector;

CalendarSelect.组件的样式:index.module.less

TypeScript 复制代码
// 日历容器整体样式
.calendar-container {
  box-sizing: border-box;
  width: 660px;
  margin-bottom: 20px;
  background-color: #fff;
  border: 1px solid #e5e5e5;
  border-radius: 4px;
}

// 表头行样式(周一到周日)
.header-row {
  position: sticky; // 固定表头
  top: 0;
  z-index: 2; // 确保在日期上方
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  grid-auto-rows: 30px; // 固定行高
  background-color: #fafafa;
}

// 表头单元格样式
.header-cell {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 94px; // 与grid列宽一致(660px/7≈94px)
  font-size: 14px;
  font-weight: 500;
  line-height: 22px;
  color: #262626;
}

// 日历主体(日期网格容器)- 关键调整
.calendar-body {
  position: relative;
  width: 100%; // 宽度占满容器

  // 日期网格布局(7列等宽)
  .grid {
    display: grid;
    grid-template-columns: repeat(7, 1fr); // 7列等宽(与容器宽度一致)
    grid-auto-rows: 70px; // 固定行高(与样式一致)
    width: 100%; // 宽度占满容器
  }

  // 单元格基础样式
  .cell {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 94px; // 固定列宽(660px/7≈94px)
    height: 70px; // 固定行高(与grid-auto-rows一致)
    cursor: pointer;
    user-select: none; // 防止文本选中
    background-color: #fff;
    border-top: 1px solid #e5e5e5;
    border-left: 1px solid #e5e5e5;
    transition: all 0.2s ease;

    // 每行第一个单元格移除左边框(已被表头覆盖)
    &:nth-child(7n + 1) {
      border-left: none;
    }

    // 最后一行单元格移除底部边框(与容器高度匹配)
    &:nth-child(7n) {
      border-bottom: none; // 修复底部边框缺失
    }

    // 占位格样式(月初/月末的空白格)
    &.placeholder {
      background-color: #fff;
    }

    // 选中状态(最终确定的范围)
    &.selected {
      position: relative;
      z-index: 1; // 确保选中单元格在上层
      background-color: #e8f7ee !important; // 统一的选中背景色

      // 起始单元格(左边界圆角)
      &.start {
        border-right: none;
        border-top-left-radius: 4px;
        border-bottom-left-radius: 4px;
      }

      // 结束单元格(右边界圆角)
      &.end {
        border-left: none;
        border-top-right-radius: 4px;
        border-bottom-right-radius: 4px;
      }
    }

    // 预选状态(hover时的范围,仅在未点击时显示)
    &:hover {
      background-color: #f7f7f7; // 与选中状态区分的浅蓝背景
    }
  }

  .content {
    display: flex;
    flex-direction: column;
    align-items: center;
  }

  // 阳历日期
  .solar-day {
    margin-top: 5px;
    font-size: 14px;
    font-weight: 500;
    color: #262626;
  }
  // 时节
  .solar-term {
    font-size: 10px;
    font-weight: 400;
    color: #8c8c8c;
  }
  // 节假日
  .holiday-tag {
    width: 70px;
    overflow: hidden; /* 超出部分隐藏 */
    font-size: 10px;
    font-weight: 400;
    color: #ff4d4f;
    text-align: center;
    text-overflow: ellipsis; /* 超出部分显示省略号 */
    white-space: nowrap; /* 禁止换行 */
  }
}
TypeScript 复制代码
  <CalendarRangeSelector
            value={calendarData} //当前选中的时间
            timeRange={timeRange} //整个日历的时间范畴
            onSelected={selectedTimeRanges => handleChange(selectedTimeRanges)}
            isRadio={isRadio} //多选还是单选模式
          >
</CalendarRangeSelector>

效果图:单选/多选

相关推荐
江公望2 分钟前
VUE3中,reactive()和ref()的区别10分钟讲清楚
前端·javascript·vue.js
攀登的牵牛花5 分钟前
前端向架构突围系列 - 框架设计(二):糟糕的代码有哪些特点?
前端·架构
EndingCoder15 分钟前
函数基础:参数和返回类型
linux·前端·ubuntu·typescript
码客前端21 分钟前
理解 Flex 布局中的 flex:1 与 min-width: 0 问题
前端·css·css3
Komorebi゛21 分钟前
【CSS】圆锥渐变流光效果边框样式实现
前端·css
工藤学编程34 分钟前
零基础学AI大模型之CoT思维链和ReAct推理行动
前端·人工智能·react.js
徐同保34 分钟前
上传文件,在前端用 pdf.js 提取 上传的pdf文件中的图片
前端·javascript·pdf
怕浪猫35 分钟前
React从入门到出门第四章 组件通讯与全局状态管理
前端·javascript·react.js
博主花神36 分钟前
【React】扩展知识点
javascript·react.js·ecmascript
欧阳天风43 分钟前
用setTimeout代替setInterval
开发语言·前端·javascript