及其复杂得轮班考勤表

114 阅读14分钟

image.png 当时看到这个原型我就头皮发麻,疯狂吐槽产品设计这么难得UI。

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { DownOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue';
import interact from 'interactjs';
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import ChangeRequestDetailsModal from './modals/changeRequestDetails.vue';
import store from '@/store';
import { replyCommentT } from '@/api/rotation/myRotationPlan';
import { useLanguage } from '@/hooks';
import { WeekDaysMap, workTagsEnum } from '@/types';
import { formatTime } from '../hooks';

dayjs.extend(isBetween);

const { t: $t } = useI18n();

interface IProps {
	id: string;
	title?: string;
	isDetail?: boolean;
	isReadOnly?: boolean;
	isB2B?: boolean;
	hasB2B?: boolean;
	gapDays?: number;
	overlapDays?: number;
	dataBeginDate?: string | null;
	isExceed9Weeks?: boolean;
	isFailed2Modified?: boolean;
	isFewerWorkingDay?: boolean;
	isFill?: boolean;
	requireWorkingDays?: number;
	canOverlapDays?: number;
	timesOfYear?: number;
	joinInfo?: {
		joinDate: string;
		joinStatus: number;
	} | null;
	employRpDetail?: any;
	workPatternTitle?: string;
}

const props = withDefaults(defineProps<IProps>(), {
	isDetail: false,
	isReadOnly: false,
	isB2B: false,
	hasB2B: false,
	gapDays: 0,
	overlapDays: 0,
	dataBeginDate: null,
	isExceed9Weeks: false,
	isFailed2Modified: false,
	isFewerWorkingDay: false,
	isFill: true,
	requireWorkingDays: 0,
	canOverlapDays: 0,
	timesOfYear: 2,
	joinInfo: null,
	employRpDetail: null,
});

const tips = computed(() => {
	return {
		M: $t('attendance.common.M'),
		R: $t('attendance.common.R'),
		T: $t('attendance.common.T'),
	};
});

const containerRef = ref();

interface IDay {
	day: number;
	duration: null | string;
	disabled: boolean;
	note: string;
	step: number;
	week: string;
}

interface IMonth {
	month: number;
	monthLabel: string;
	monthDayCount: number;
	days: IDay[];
}

const months = ref<IMonth[]>([]);
const pureMonthData = ref<
	{
		month: number;
		days: { day: number; disabled: boolean }[];
	}[]
>([]);

const days = ref(Array.from({ length: 31 }, (_, i) => i + 1));

let year = 0;
let month = 0;
/**
 * 动态获取上/下半年的月份和天数
 */
const getMonthData = () => {
	const allMonthNames = [
		'jan',
		'feb',
		'mar',
		'apr',
		'may',
		'jun',
		'jul',
		'aug',
		'sep',
		'oct',
		'nov',
		'dec',
	].map((item) => `attendance.common.${item}`);
	const allMonthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; // 平年天数

	if (props.joinInfo && props.joinInfo.joinStatus == 0 && !props.isB2B) {
		const { joinDate } = props.joinInfo;
		const [joinYear, joinMonth] = joinDate.split('-');
		year = +joinYear;
		month = parseInt(joinMonth) - 1;

		let timesOfYear = props.timesOfYear; // 存在1 2 4 三种, 1渲染全年  2根据月份渲染上/下半年   4渲染季节

		let start = +joinMonth - 1,
			end = 11;
		if (timesOfYear == 1) {
			end = 11;
		} else if (timesOfYear == 2) {
			if (month < 6) {
				end = 5;
			} else {
				end = 11;
			}
		} else if (timesOfYear == 4) {
			// 1-3 渲染 4-6 , 4-6渲染7-9, 7-9渲染 10-12 , 10-12 渲染 1-3
			if (month < 3) {
				end = 2;
			} else if (month < 6) {
				end = 5;
			} else if (month < 9) {
				end = 8;
			} else {
				end = 11;
			}
		}

		// 判断是否为闰年
		const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || year % 470 === 0;
		if (isLeapYear) {
			allMonthDays[1] = 29; // 闰年2月天数
		}

		let monthNames = [];
		let monthDays = [];

		// 遍历每个月生成数据
		for (let i = +joinMonth; i <= +joinMonth; i++) {
			const currentMonthDays = allMonthDays[i];
			let days = [];

			if (i === month) {
				// 如果是入职的第一个月,从入职日开始算起
				for (let j = +joinMonth; j <= currentMonthDays; j++) {
					days.push(j);
				}
			} else {
				// 其他月份全部天数
				for (let j = 1; j <= currentMonthDays; j++) {
					days.push(j);
				}
			}

			monthNames.push(allMonthNames[i]);
			monthDays.push(days);
		}

		// 获取指定半年的月份名称和天数
		monthNames = allMonthNames.slice(start, end + 1);
		monthDays = allMonthDays.slice(start, end + 1);

		return {
			monthNames,
			monthDays,
			startMonth: start + 1,
			startDate: props.joinInfo.joinDate,
			endDate: `${year}-${`${end + 1}`.padStart(2, '0')}-${`${
				monthDays[monthDays.length - 1]
			}`.padStart(2, '0')}`,
		};
	} else {
		// 有数据时就根据数据生成月份数据,无则获取当前月份的月份数据
		if (props.dataBeginDate) {
			const [BYear, BMonth] = props.dataBeginDate.split('-');
			year = +BYear;
			month = parseInt(BMonth) - 1;
		}

		/**
		 * @description 上半季度填写的是下半年7-12月分轮班, 下半季度填写的是明年1-6月排班
		 */
		let timesOfYear = props.timesOfYear; // 存在1 2 4 三种, 1渲染全年  2根据月份渲染上/下半年   4渲染季节

		let start = 0,
			end = 11;
		if (timesOfYear == 1) {
			year += 1;
			start = 0;
			end = 11;
		} else if (timesOfYear == 2) {
			if (month < 6) {
				start = 6;
				end = 11;
			} else {
				year += 1;
				start = 0;
				end = 5;
			}
		} else if (timesOfYear == 4) {
			// 1-3 渲染 4-6 , 4-6渲染7-9, 7-9渲染 10-12 , 10-12 渲染 1-3
			if (month < 3) {
				start = 3;
				end = 5;
			} else if (month < 6) {
				start = 6;
				end = 8;
			} else if (month < 9) {
				start = 9;
				end = 11;
			} else {
				year += 1;

				start = 0;
				end = 2;
			}
		}

		// 判断是否为闰年
		const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || year % 470 === 0;
		if (isLeapYear) {
			allMonthDays[1] = 29; // 闰年2月天数
		}

		// 获取指定半年的月份名称和天数
		const monthNames = allMonthNames.slice(start, end + 1);
		const monthDays = allMonthDays.slice(start, end + 1);

		return {
			monthNames,
			monthDays,
			startMonth: start + 1,
			startDate: `${year}-${`${start + 1}`.padStart(2, '0')}-01`,
			endDate: `${year}-${`${end + 1}`.padStart(2, '0')}-${`${
				monthDays[monthDays.length - 1]
			}`.padStart(2, '0')}`,
		};
	}
};

interface IEmit {
	(event: 'getMonthData', value: any): void;
}
const $emit = defineEmits<IEmit>();

/**
 * @description 生成月份数据
 */
const generateMonthsData = () => {
	const { monthNames, monthDays, startMonth } = getMonthData();
	// 返回月份数据
	$emit('getMonthData', { ...getMonthData(), year });

	const tmpArr = [];

	const pureData = [];

	for (let i = 0; i < monthNames.length; i++) {
		const daysArray = Array.from({ length: monthDays[i] }, (_, dayIndex) => ({
			day: dayIndex + 1,
			duration: null,
			disabled:
				dayIndex + 1 > monthDays[i] ||
				(!props.isB2B &&
					props.joinInfo &&
					dayjs(`${year}-${i + startMonth}-${dayIndex + 1}`).isBefore(
						dayjs(props.joinInfo.joinDate),
					)),
			note: '',
			step: 0,
			week: WeekDaysMap[
				dayjs(`${year}-${i + startMonth}-${dayIndex + 1}`).day()
			],
		})) as IDay[];

		tmpArr.push({
			month: i + startMonth,
			monthLabel: monthNames[i],
			monthDayCount: monthDays[i],
			days: daysArray,
		});

		pureData.push({
			month: i + startMonth,
			days: daysArray.map((item) => {
				return {
					day: item.day,
					disabled: item.disabled,
				};
			}),
		});
	}

	months.value = tmpArr;
	pureMonthData.value = pureData;

	return new Promise((resolve) => {
		resolve(true);
	});
};

const isDragging = ref(false);
const currentCell = ref<null | string>(null);

const monthEleWidth = 119;
const dayEleWidth = 47;
const dayEleHeight = 30;

const isAddComment = ref(false);
const editActiveIndex = ref<string | null>(null);

const activeCellIndex = ref<string | null>(null);
const activeDayIndex = ref<string | null>(null);

const { isArabic } = useLanguage();

/**
 * @description 单元格绑定拖动事件
 */
const bindResizableEvent = () => {
	const edges = {
		arabic: { right: false, left: true },
		default: { right: true, left: false },
	};

	interact(`#${props.id} .duration`).resizable({
		edges: {
			top: false,
			bottom: false,
			...edges[isArabic.value ? 'arabic' : 'default'],
		},
		modifiers: [
			interact.modifiers.restrictSize({
				min: { width: dayEleWidth, height: dayEleHeight },
			}),
		],
		listeners: {
			start(event) {
				isDragging.value = true;

				const target = event.target;
				target.style.zIndex = 101;
				currentCell.value = target.dataset.current;

				isAddComment.value = false;
				editActiveIndex.value = null;
			},
			move(event) {
				var target = event.target;

				const count = +target.dataset.count; // 月份最大天数

				const [month, day] = target.dataset.current
					.split('-')
					.map((item: string) => +item); // 当前操作时间 月,日

				let { x, y } = event.target.dataset;

				x = (parseFloat(x) || 0) + event.deltaRect.left;
				y = (parseFloat(y) || 0) + event.deltaRect.top;

				// 查找下一个元素,限制其最大拖动宽度
				let nextTarget = null;
				let nextTargetOffsetLeft = 0;
				const durationGroup = target
					.closest('.gantt-row')
					.querySelectorAll(`#${props.id} .duration`);
				const currentTargetIndex = Object.values(durationGroup).findIndex(
					(item: any) => item.dataset.current == `${month}-${day}`,
				);

				if (currentTargetIndex + 1 < durationGroup.length) {
					nextTarget = Object.values(durationGroup)[
						currentTargetIndex + 1
					] as any;
					// eslint-disable-next-line no-unused-vars
					const [_, nextTargetDay] = nextTarget.dataset.current
						.split('-')
						.map((item: string) => +item); // 当前操作时间 月,日
					nextTargetOffsetLeft =
						containerRef.value.clientWidth -
						monthEleWidth -
						(count - nextTargetDay + (31 - count)) * dayEleWidth -
						day * dayEleWidth;
				}
				// 无下一个元素时,最大拖动宽度
				const maxWidthWhenNoNext = (count - day + 1) * dayEleWidth;

				// 限制最大拖动宽度
				const width = Math.min(
					Math.round(event.rect.width / dayEleWidth) * dayEleWidth,
					nextTarget ? nextTargetOffsetLeft : maxWidthWhenNoNext,
				);

				const height = (target.style.height = event.rect.height + 'px');

				const dragArea = target.querySelector('.drag_area');

				event.target.style.width = `${width}px`;
				event.target.style.height = `${height}px`;

				dragArea.style.width = `${width - dayEleWidth}px`;

				dragArea.style.height = `${height}px`;

				event.target.dataset.x = x;
				event.target.dataset.y = y;
			},
			end(event) {
				isDragging.value = false;

				event.target.style.zIndex = '';
				currentCell.value = null;

				const dragArea = event.target.querySelector('.drag_area');
				// 拖动了几个格子
				const dragStep = dragArea.clientWidth / dayEleWidth;

				const [month, day] = event.target.dataset.current
					.split('-')
					.map((item: string) => +item); // 当前操作时间 月,日

				const monthInfo = months.value.find((k: IMonth) => k.month == month);
				if (monthInfo) {
					const dayInfo = monthInfo.days.find((k: IDay) => k.day == day);
					if (dayInfo) {
						dayInfo.step = dragStep;
					}
				}
			},
		},
	});
};

/**
 * 重置初始值
 */
const resetDefault = () => {
	activeCellIndex.value = null;
	activeDayIndex.value = null;
	isAddComment.value = false;
	editActiveIndex.value = null;
};

/**
 * @description 添加全局点击事件监听器
 */
const addGlobalClickListener = () => {
	document.addEventListener('click', function (event) {
		// 检查点击的目标对象及其祖先元素是否包含.duration类
		let targetElement = event.target as any;
		let containsDurationClass = false;

		while (targetElement) {
			if (targetElement.classList && targetElement.classList.contains('day')) {
				containsDurationClass = true;
				break;
			}
			targetElement = targetElement.parentElement;
		}

		// 如果点击的对象不包含.duration类,则清空容器内容
		if (!containsDurationClass) {
			resetDefault();
		}
	});
};

onMounted(() => {
	// 添加全局点击事件监听器
	addGlobalClickListener();
});

//是否渲染完成
const isComplete = ref(false);

watch(
	() => props.dataBeginDate,
	(newValue, oldValue) => {
		if (newValue && newValue != oldValue) {
			// 生成月份数据
			generateMonthsData()
				.then(() => {
					// 单元格绑定拖动事件
					bindResizableEvent();
				})
				.finally(() => {
					isComplete.value = true;
				});
		}
	},
	{ immediate: true },
);

const { proxy } = getCurrentInstance() as any;
watch(
	() => proxy.$i18n.locale,
	(newLocale, oldLocale) => {
		if (newLocale !== oldLocale) {
			bindResizableEvent();
		}
	},
	{
		immediate: true,
	},
);

// 轮班类型
const rotationPlanTypes = [
	{ label: 'M', value: 'M' },
	{ label: 'R', value: 'R' },
	{ label: 'T (A)', value: 'Arrive_T' },
	{ label: 'T (L)', value: 'Leave_T' },
];

/**
 * @description 最外层cell点击事件
 */
const handleCellClick = (month: IMonth, day: IDay) => {
	if (!isDragging.value) {
		activeCellIndex.value = `${month.month}-cell-${day.day}`;
		isAddComment.value = false;
		editActiveIndex.value = null;
	}
};

/**
 * @description 鼠标左击事件
 */
const handleDayClick = (month: IMonth, day: IDay) => {
	activeDayIndex.value = `${month.monthLabel}-day-${day.day}`;
	activeCellIndex.value = null;
	isAddComment.value = false;
	editActiveIndex.value = null;
};

/**
 * @description 鼠标右击事件
 */
const handleRightDayClick = (event: Event, month: IMonth, day: IDay) => {
	isAddComment.value = true;
	editActiveIndex.value = `${month.month}-edit-${day.day}`;

	// input focus
	const target = event.target as any;
	if (target) {
		setTimeout(() => {
			const input = target.closest('.duration').querySelector('input');
			if (input) {
				input.focus();
			}
		}, 200);
	}
};

const hoverDayIndex = ref<string | null>(null);
const handleDayMouseenter = (month: IMonth, day: IDay) => {
	hoverDayIndex.value = `${month.monthLabel}${day.day}`;
};

const handleDayMouseleave = () => {
	hoverDayIndex.value = null;
	activeDayIndex.value = null;
};

/**
 * @description 选择类型
 */
const handlePlanTypeClick = (day: IDay, duration: string) => {
	day.duration = duration;
	activeDayIndex.value = null;
};

/**
 * @description 删除durantion
 */
const keyboardDownHandle = (evt: KeyboardEvent) => {
	if (activeCellIndex.value && ['Delete', 'Backspace'].includes(evt.key)) {
		const [month, day] = activeCellIndex.value
			.split('-cell-')
			.map((item: string) => +item);

		const current = months.value
			.filter((item: any) => item.month == month)[0]
			.days.filter((k: any) => k.day == day)[0];

		current.duration = null;
		current.step = 0;
		current.note = '';
	}
};

onMounted(() => {
	document.addEventListener('keydown', keyboardDownHandle, true);
});

onUnmounted(() => {
	document.removeEventListener('keydown', keyboardDownHandle, true);
});

/**
 * @description 导出数据
 */
const exportData = (tagWork?: { [key: string]: string }) => {
	const result = months.value.reduce((pre: any, month: IMonth) => {
		const arr = month.days.reduce((blocks: any, day: IDay) => {
			if (day.duration) {
				blocks.push({
					beginDate: `${month.month}-${day.day}`,
					endDate: `${month.month}-${+day.day + +day.step}`,
					workTag: tagWork ? +tagWork[day.duration as string] : day.duration,
					remark: day.note,
				});
			}
			return blocks;
		}, []);
		return [...pre, ...arr];
	}, []);

	return { year, rpData: result };
};

/**
 * @description 数据回显
 */
const importData = debounce((data: any) => {
	data.forEach((item: any) => {
		const { beginDate, endDate, workTag, remark } = item;

		const curMonth = new Date(beginDate).getMonth() + 1;
		const startDay = new Date(beginDate).getDate();
		const diffInDays = dayjs(endDate).diff(dayjs(beginDate), 'day');

		const month = months.value.find((k: any) => k.month == curMonth);
		if (month) {
			const day = month.days.find((day: any) => day.day == startDay) as IDay;
			day.duration = workTag;
			day.note = remark ?? '';
			day.step = diffInDays;

			requestAnimationFrame(() => {
				const durationEle = document.querySelector(
					`#${props.id} .duration[data-current="${curMonth}-${startDay}"]`,
				) as HTMLElement;
				if (durationEle) {
					const newWidth = diffInDays * dayEleWidth + dayEleWidth;
					const dragArea = durationEle.getElementsByClassName(
						'drag_area',
					)[0] as HTMLElement;

					// 只有在宽度变化时才修改样式,避免不必要的更新
					if (durationEle.style.width !== `${newWidth}px`) {
						durationEle.style.width = `${newWidth}px`;
					}

					if (
						dragArea &&
						dragArea.style.width !== `${diffInDays * dayEleWidth}px`
					) {
						dragArea.style.width = `${diffInDays * dayEleWidth}px`;
					}
				}
			});
		}
	});
}, 500);

const workPattern = computed(() => {
	if (props.employRpDetail) {
		const { workPatternDTO, workPattern, b2bWorkPattern, workPatternName } =
			props.employRpDetail;
		if (props.isB2B) {
			return b2bWorkPattern ?? '--';
		} else {
			return (workPattern || workPatternDTO?.name || workPatternName) ?? '--';
		}
	}
	return '';
});

/**
 * @description Total Working Days
 */
const totalWorkingDays = computed(() => {
	return months.value.reduce((pre: number, month: IMonth) => {
		const workingDays = month.days.reduce((workingDays: number, day: IDay) => {
			if (day.duration && day.duration == 'M') {
				workingDays += day.step + 1;
			}
			if (
				day.duration &&
				(day.duration == 'Leave_T' || day.duration == 'Arrive_T')
			) {
				workingDays += day.step + 0.5;
			}
			return workingDays;
		}, 0);
		return pre + workingDays;
	}, 0);
});

/**
 * @description Total Off Days
 */
const totalOffDays = computed(() => {
	return months.value.reduce((pre: number, month: IMonth) => {
		const workingDays = month.days.reduce((workingDays: number, day: IDay) => {
			if (day.duration && day.duration == 'R') {
				workingDays += day.step + 1;
			}
			if (
				day.duration &&
				(day.duration == 'Leave_T' || day.duration == 'Arrive_T')
			) {
				workingDays += day.step + 0.5;
			}
			return workingDays;
		}, 0);
		return pre + workingDays;
	}, 0);
});

// 修改历史数组
const planChangedArr = ref<any>([]);
const ganttBodyRef = ref();

/**
 * @description Details时显示plan changed历史
 */
const displayPlanChanged = (data: any) => {
	if (!data || !data.length) return;

	setTimeout(() => {
		const parentRect = ganttBodyRef.value.getBoundingClientRect();

		const result = data.reduce((pre: any, cur: any) => {
			const { beginDate, endDate } = cur;

			// 1.1 查找开始位置,获取left
			const [startMonth, startDay] = dayjs(beginDate).format('M-D').split('-');

			const dayEle = document.querySelector(
				`.day[data-current="${startMonth}-${startDay}"]`,
			) as Element;
			const { left, top } = dayEle.getBoundingClientRect();
			cur['left'] = left - parentRect.left;
			cur['top'] = top - parentRect.top;

			// 2.2 计算两个日期相差天数,获得元素宽
			const date1 = dayjs(beginDate, 'YYYY-MM-DD');
			const date2 = dayjs(endDate, 'YYYY-MM-DD');
			const diffDays = date2.diff(date1, 'day') + 1;
			cur['width'] = diffDays * dayEleWidth;

			pre.push(cur);

			return pre;
		}, []);

		planChangedArr.value = result;
	});
};

const open = ref(false);

const currentPlanChangedItem = ref<any>(null);
/**
 * @description 打开Change Request Details 弹窗
 */
const openRequestDetail = (item: any) => {
	open.value = true;

	currentPlanChangedItem.value = item;
};

// ============================================ comment ====

const username = store.getters.getUserInfo.username;
const comment = ref('');

const commentArr = ref<any>([]);
/**
 * @description Details时显示plan changed历史
 */
const displayCommentBlock = (data: any) => {
	if (!data || !data.length) return;

	setTimeout(() => {
		const parentRect = ganttBodyRef.value.getBoundingClientRect();

		const result = data.reduce((pre: any, cur: any) => {
			if (cur.commentList && cur.commentList.length) {
				const { beginDate, endDate } = cur;

				// 1.1 查找开始位置,获取left
				const [startMonth, startDay] = dayjs(beginDate)
					.format('M-D')
					.split('-');

				const dayEle = document.querySelector(
					`.day[data-current="${startMonth}-${startDay}"]`,
				) as Element;
				const { left, top } = dayEle.getBoundingClientRect();
				cur['left'] = left - parentRect.left;
				cur['top'] = top - parentRect.top;

				// 2.2 计算两个日期相差天数,获得元素宽
				const date1 = dayjs(beginDate, 'YYYY-MM-DD');
				const date2 = dayjs(endDate, 'YYYY-MM-DD');
				const diffDays = date2.diff(date1, 'day') + 1;
				cur['width'] = diffDays * dayEleWidth;

				pre.push(cur);
			}

			return pre;
		}, []);

		commentArr.value = result;
	});
};
//================================================  Overlap & Gap 判断 ===================

const overlapArr = ref<any>([]);
/**
 * @description 显示重叠日期
 */
const displayOverlapBlock = (data: any) => {
	if (!data) return;

	const parentRect = ganttBodyRef.value.getBoundingClientRect();

	const result = data.reduce((pre: any, cur: any) => {
		const { beginDate, endDate } = cur;

		// 1.1 查找开始位置,获取left
		const [startMonth, startDay] = dayjs(beginDate).format('M-D').split('-');

		const dayEle = document.querySelector(
			`.day[data-current="${startMonth}-${startDay}"]`,
		) as Element;
		const { left, top } = dayEle.getBoundingClientRect();
		cur['left'] = left - parentRect.left;
		cur['top'] = top - parentRect.top;

		// 2.2 计算两个日期相差天数,获得元素宽
		const date1 = dayjs(beginDate, 'YYYY-MM-DD');
		const date2 = dayjs(endDate, 'YYYY-MM-DD');
		const diffDays = date2.diff(date1, 'day') + 1;
		cur['width'] = diffDays * dayEleWidth;

		pre.push(cur);

		return pre;
	}, []);

	overlapArr.value = result;
};

const gapArr = ref<any>([]);
/**
 * @description 显示Gap日期
 */
const displayGapBlock = (data: any) => {
	if (!data) return;

	const parentRect = ganttBodyRef.value.getBoundingClientRect();

	const result = data.reduce((pre: any, cur: any) => {
		const { beginDate, endDate } = cur;

		// 1.1 查找开始位置,获取left
		const [startMonth, startDay] = dayjs(beginDate).format('M-D').split('-');

		const dayEle = document.querySelector(
			`.day[data-current="${startMonth}-${startDay}"]`,
		) as Element;
		const { left, top } = dayEle.getBoundingClientRect();
		cur['left'] = left - parentRect.left;
		cur['top'] = top - parentRect.top;

		// 2.2 计算两个日期相差天数,获得元素宽
		const date1 = dayjs(beginDate, 'YYYY-MM-DD');
		const date2 = dayjs(endDate, 'YYYY-MM-DD');
		const diffDays = date2.diff(date1, 'day') + 1;
		cur['width'] = diffDays * dayEleWidth;

		pre.push(cur);

		return pre;
	}, []);

	gapArr.value = result;
};

defineExpose({
	isComplete,
	totalWorkingDays,
	totalOffDays,
	exportData,
	importData,
	displayPlanChanged,
	displayCommentBlock,
	displayOverlapBlock,
	displayGapBlock,
});

/**
 * @description 回复评论
 */
const replyComment = (record: any) => {
	if (comment.value.length && record) {
		replyCommentT({
			beginDate: record.beginDate,
			endDate: record.endDate,
			planDetailId: record.id,
			content: comment.value,
		}).then((res) => {
			if (res.code == 200) {
				record.commentList.push({
					comment: comment.value,
					createTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
					employeeName: username,
				});
				comment.value = '';
			}
		});
	}
};

/**
 * @description 初始化数据
 */
const resetData = () => {
	generateMonthsData();
};

const calculateExecutedDays = (
	{
		workDays,
		workTag,
	}: {
		workDays: number;
		workTag: string;
	},
	workPattern: any,
): { [key: string]: number } => {
	const executedDays = {
		arrivalTravelDays: 0,
		workDays: 0,
		leaveTravelDays: 0,
		rotationDays: 0,
	};

	switch (workTag) {
		case 'Arrive_T':
			executedDays.arrivalTravelDays = workPattern.arrivalTravelDays;
			break;
		case 'M':
			executedDays.arrivalTravelDays = workPattern.arrivalTravelDays;
			executedDays.workDays = workDays;
			break;
		case 'Leave_T':
			executedDays.arrivalTravelDays = workPattern.arrivalTravelDays;
			executedDays.workDays = workPattern.workDays;
			executedDays.leaveTravelDays = workPattern.leaveTravelDays;
			break;
		case 'R':
			executedDays.arrivalTravelDays = workPattern.arrivalTravelDays;
			executedDays.workDays = workPattern.workDays;
			executedDays.leaveTravelDays = workPattern.leaveTravelDays;
			executedDays.rotationDays = workDays;
			break;
		default:
			throw new Error('Unknown workTag');
	}

	return executedDays;
};

/**
 * @description 自动填充RP
 */
const autofill = () => {
	if (!isComplete.value) return;
	resetData();

	const {
		workPatternDTO,
		lastRotationPlanDetailDTO,
		b2bWorkPattern,
		workPattern,
	} = props.employRpDetail;

	// 月份数据
	const calendarData = pureMonthData.value;

	// workPattern工作模式
	const WorkPatternMap = {
		arrivalTravelDays: workPatternDTO?.arrivalTravelDays || 0,
		workDays: workPatternDTO?.workDays || 0,
		leaveTravelDays: workPatternDTO?.leaveTravelDays || 0,
		rotationDays: workPatternDTO?.rotationDays || 0,
	};

	const workTags = {
		arrivalTravelDays: 'Arrive_T',
		workDays: 'M',
		leaveTravelDays: 'Leave_T',
		rotationDays: 'R',
	};

	const results = [] as {
		beginDate: string;
		endDate: string;
		workTag: string;
	}[];
	let originalWorkPattern = { ...WorkPatternMap };

	// 1. 对上个季度最后一段数据进行处理, 如果workDay/workTag为null, 则不处理
	const lastQuarterData = lastRotationPlanDetailDTO;
	if (lastQuarterData.workDays && lastQuarterData.workTag) {
		const executedDays = calculateExecutedDays(lastQuarterData, WorkPatternMap);
		originalWorkPattern = Object.keys(originalWorkPattern).reduce(
			(acc: any, key) => {
				acc[key] -= executedDays[key] || 0;
				return acc;
			},
			originalWorkPattern,
		);
	}

	// 2. 生成RP数据
	const addResult = (beginDate: string, workTag: string) => {
		const dateString = dayjs(beginDate).format('YYYY-MM-DD');
		const lastEntry = results[results.length - 1];
		if (lastEntry && lastEntry.workTag === workTag) {
			lastEntry.endDate = dateString; // 更新最后一个条目的endDate
		} else {
			results.push({ beginDate: dateString, endDate: dateString, workTag });
		}
	};

	// 是否b2b有数据
	const hasB2BData =
		props.employRpDetail.b2bEmployeeRotationPlanDetailDTOList &&
		props.employRpDetail.b2bEmployeeRotationPlanDetailDTOList.length > 0;

	const [M, R] = workPattern.split('/');

	// 有b2b数据,根据b2b的数据反向生成数据 T_A -> T_L, M -> R
	// 对称的才进行反向生成  2024/11/18
	if (hasB2BData && b2bWorkPattern === workPattern && +M == +R) {
		const { b2bEmployeeRotationPlanDetailDTOList } = props.employRpDetail;
		calendarData.forEach((month) => {
			let currentDayIndex = 0;

			while (currentDayIndex < month.days.length) {
				const day = month.days[currentDayIndex];
				const dateString = `${year}-${String(month.month).padStart(
					2,
					'0',
				)}-${String(day.day).padStart(2, '0')}`;

				// 对disabled的日期进行跳过,因为可能为新员工,入职日期不一定是从1号开始的,disabled在之前的方法中已进行了判断,所以这里无需判断
				if (day.disabled) {
					currentDayIndex++;
					continue;
				}

				const targetDate = dayjs(dateString);
				const result = b2bEmployeeRotationPlanDetailDTOList.find(
					(item: any) => {
						const beginDate = dayjs(item.beginDate);
						const endDate = dayjs(item.endDate);
						return targetDate.isBetween(beginDate, endDate, null, '[]'); // '[]' 表示包含边界
					},
				);

				const reverseWorkTag: any = {
					[workTagsEnum.arrive]: workTagsEnum.leave, // T_A -> T_L,
					[workTagsEnum.m]: workTagsEnum.r, // M -> R,
					[workTagsEnum.leave]: workTagsEnum.arrive, // T_L -> T_A,
					[workTagsEnum.r]: workTagsEnum.m, // R -> M,
				};

				const WorkTagMap: any = {
					[workTagsEnum.arrive]: 'Arrive_T',
					[workTagsEnum.m]: 'M',
					[workTagsEnum.leave]: 'Leave_T',
					[workTagsEnum.r]: 'R',
				};

				if (result) {
					addResult(dateString, WorkTagMap[reverseWorkTag[result.workTag]]);
				}

				currentDayIndex++;
			}
		});

		// 如果上一期没数据, 将第一天workTag改为Arrive_T类型
		if (!lastQuarterData.workDays || !lastQuarterData.workTag) {
			const earliestDate = results[0].beginDate;
			results[0].beginDate = dayjs(earliestDate)
				.add(1, 'day')
				.format('YYYY-MM-DD');
			results.unshift({
				beginDate: earliestDate,
				endDate: earliestDate,
				workTag: 'Arrive_T',
			});
		}
	} else {
		calendarData.forEach((month) => {
			let currentDayIndex = 0;

			while (currentDayIndex < month.days.length) {
				const day = month.days[currentDayIndex];
				const dateString = `${year}-${String(month.month).padStart(
					2,
					'0',
				)}-${String(day.day).padStart(2, '0')}`;

				// 对disabled的日期进行跳过,因为可能为新员工,入职日期不一定是从1号开始的,disabled在之前的方法中已进行了判断,所以这里无需判断
				if (day.disabled) {
					currentDayIndex++;
					continue;
				}

				// 有b2b数据,根据b2b的数据反向生成数据 T_A -> T_L , M -> R
				if (originalWorkPattern.arrivalTravelDays > 0) {
					addResult(dateString, workTags.arrivalTravelDays);
					originalWorkPattern.arrivalTravelDays--;
				} else if (originalWorkPattern.workDays > 0) {
					addResult(dateString, workTags.workDays);
					originalWorkPattern.workDays--;
				} else if (originalWorkPattern.leaveTravelDays > 0) {
					addResult(dateString, workTags.leaveTravelDays);
					originalWorkPattern.leaveTravelDays--;
				} else if (originalWorkPattern.rotationDays > 0) {
					addResult(dateString, workTags.rotationDays);
					originalWorkPattern.rotationDays--;
				}

				// 如果所有工作模式都已执行完,则重置
				if (Object.values(originalWorkPattern).every((v) => v === 0)) {
					originalWorkPattern = { ...WorkPatternMap };
				}

				currentDayIndex++;
			}
		});
	}

	// 3. 对跨月的数据进行拆分,考虑跨多个月的情况
	const finalResults = [] as {
		beginDate: string;
		endDate: string;
		workTag: string;
	}[];

	// 辅助函数,用于创建结果并推入finalResults
	const pushResult = (beginDate: string, endDate: string, workTag: string) => {
		finalResults.push({
			beginDate: dayjs(beginDate).format('YYYY-MM-DD'),
			endDate: dayjs(endDate).format('YYYY-MM-DD'),
			workTag,
		});
	};

	results.forEach((result) => {
		let beginDate = dayjs(result.beginDate);
		let endDate = dayjs(result.endDate);

		// 如果开始日期和结束日期不在同一月份,进行跨月拆分
		while (!beginDate.isSame(endDate, 'month')) {
			// 当前月的最后一天
			const midMonthLastDay = beginDate.endOf('month');

			// 将从beginDate到当前月的最后一天的数据推入结果
			pushResult(
				beginDate.format('YYYY-MM-DD'),
				midMonthLastDay.format('YYYY-MM-DD'),
				result.workTag,
			);

			// 更新beginDate为下个月的第一天
			beginDate = midMonthLastDay.add(1, 'day');
		}

		// 如果在同一月份,或者已经处理到最后一个月,将剩余的日期段推入结果
		pushResult(
			beginDate.format('YYYY-MM-DD'),
			endDate.format('YYYY-MM-DD'),
			result.workTag,
		);
	});

	// 输出结果
	importData(finalResults);
};

const workPatternTitle = computed(() => {
	if (props.workPatternTitle) {
		return props.workPatternTitle;
	}
	return $t('attendance.rotation.workPattern');
});
</script>

<template>
	<div :id="props.id" class="rotationPlanDesign">
		<div class="rotationPlanDesign-header">
			<div class="first-floor">
				<div class="title">
					{{ props.title ?? $t('attendance.rotation.planDesign.title') }}
				</div>
				<div class="tips">
					<div v-for="(value, key) in tips" :key="key" class="tip-item">
						<span class="icon" :class="[key]">{{ key }}</span>
						<span class="msg">{{ value }}</span>
					</div>
				</div>
			</div>
			<div class="last-floor" :class="{ detail: props.isDetail }">
				<div class="last-floor-left">
					<span class="total">
						<span class="key"> {{ workPatternTitle }}: </span>
						<span class="value">{{ workPattern }}</span>
					</span>
					<span class="total">
						<span class="key">
							{{ $t('attendance.rotation.planDesign.workingDays') }}:
						</span>
						<span class="value">{{ totalWorkingDays }}</span>
					</span>
					<span class="total">
						<span class="key">
							{{ $t('attendance.rotation.planDesign.offDays') }}:
						</span>
						<span class="value">{{ totalOffDays }}</span>
					</span>
					<template
						v-if="
							props.hasB2B &&
							['28/28', '14/14', '7/7'].includes(
								props.employRpDetail.workPattern,
							)
						"
					>
						<span v-if="props.overlapDays > 0" class="total">
							<span class="key">
								{{ $t('attendance.rotation.planDesign.overlap') }}:
							</span>
							<span class="value" style="color: #cb0000">{{
								props.overlapDays
							}}</span>
						</span>
						<span v-if="props.gapDays > 0" class="total">
							<span class="key">
								{{ $t('attendance.rotation.planDesign.gap') }}:
							</span>
							<span class="value" style="color: #cb0000">{{
								props.gapDays
							}}</span>
						</span>
					</template>
				</div>
				<div class="last-floor-right">
					<template v-if="!props.isB2B && !props.isReadOnly && !props.isDetail">
						<span
							v-if="!totalWorkingDays && !totalOffDays"
							class="autofill"
							@click="autofill"
							>{{ $t('attendance.rotation.planDesign.autoFill') }}
						</span>
						<template v-else>
							<a-popconfirm
								ok-text="OK"
								cancel-text="Cancel"
								placement="left"
								@confirm="autofill"
							>
								<template #icon>
									<ExclamationCircleOutlined style="color: red" />
								</template>
								<template #title>
									<span style="color: #131211">
										<i18n-t
											keypath="attendance.rotation.planDesign.autoFillTip"
										>
											<template #br>
												<br />
											</template>
										</i18n-t>
									</span>
								</template>
								<span class="autofill">{{
									$t('attendance.rotation.planDesign.autoFill')
								}}</span>
							</a-popconfirm>
						</template>
					</template>
				</div>
			</div>
			<div v-if="props.isExceed9Weeks" class="overlap-tips">
				{{ $t('attendance.rotation.planDesign.exceed9WeeksTip') }}
			</div>
			<div v-if="props.isFailed2Modified" class="overlap-tips">
				{{ $t('attendance.rotation.planDesign.failed2Modified') }}
			</div>
			<div v-if="!props.isFill" class="overlap-tips">
				{{ $t('attendance.rotation.planDesign.notFillTip') }}
			</div>
			<div
				v-if="props.overlapDays > props.canOverlapDays || props.gapDays > 0"
				class="overlap-tips"
			>
				{{ $t('attendance.rotation.planDesign.overlapOrGapTip') }}
			</div>
			<div
				v-if="props.isFewerWorkingDay"
				class="overlap-tips"
				style="color: #ff7a00"
			>
				{{
					$t('attendance.rotation.planDesign.fewerWorkingDayTip', {
						requireWorkingDays: props.requireWorkingDays,
					})
				}}
			</div>
		</div>
		<div
			class="rotationPlanDesign-content"
			:style="{ transform: isArabic ? 'rotateY(180deg)' : 'none' }"
		>
			<div
				ref="containerRef"
				class="gantt-container"
				:class="{ 'read-only': isReadOnly }"
			>
				<div class="gantt-header">
					<div class="gantt-cell empty"></div>
					<div v-for="dayItem in days" :key="dayItem" class="gantt-cell">
						{{ dayItem }}
					</div>
				</div>
				<div ref="ganttBodyRef" class="gantt-body">
					<div
						v-for="(month, monthIndex) in months"
						:key="month.month"
						class="gantt-row"
					>
						<div class="gantt-cell month">{{ $t(month.monthLabel) }}.</div>
						<div
							v-for="day in month.days"
							:key="day.day"
							class="gantt-cell day"
							:class="{ disabled: day.disabled }"
							:data-current="`${month.month}-${day.day}`"
						>
							<span class="week">{{ $t(day.week) }}</span>
							<div
								v-if="!day.disabled"
								class="action"
								@mouseenter="handleDayMouseenter(month, day)"
								@mouseleave="handleDayMouseleave()"
							>
								<template
									v-if="
										hoverDayIndex == `${month.monthLabel}${day.day}` &&
										!isDragging
									"
								>
									<div
										v-if="!day.duration"
										class="grid dropdown"
										:class="{
											active: hoverDayIndex == `${month.monthLabel}${day.day}`,
										}"
										@click="handleDayClick(month, day)"
									>
										<DownOutlined />
									</div>
								</template>

								<div
									v-if="day.duration"
									class="grid duration"
									:class="{
										[day.duration]: true,
										hideAfter:
											isDragging && currentCell !== `${month.month}-${day.day}`,
										active:
											activeCellIndex === `${month.month}-cell-${day.day}` &&
											!isAddComment,
										addCommentActive:
											isAddComment &&
											editActiveIndex === `${month.month}-edit-${day.day}`,
										hasComment:
											day.note.length &&
											editActiveIndex !== `${month.month}-edit-${day.day}`,
									}"
									:data-count="month.monthDayCount"
									:data-current="`${month.month}-${day.day}`"
									:data-duration="day.duration"
									@click="handleCellClick(month, day)"
									@click.right.prevent="
										(e) => handleRightDayClick(e, month, day)
									"
								>
									<a-tooltip
										color="#fff"
										:overlay-inner-style="{
											color: '#66809E',
											padding: '8px 12px 2px',
											fontSize: '12px',
											width: '389px',
										}"
										:placement="monthIndex > 3 ? 'bottom' : 'top'"
									>
										<template #title>
											<ul style="padding: 0; list-style: none">
												<li style="text-align: left">
													• {{ $t('attendance.rotation.planDesign.dragEdge') }}
												</li>
												<li style="text-align: left">
													•
													{{
														$t('attendance.rotation.planDesign.clickRightMouse')
													}}
												</li>
												<li style="text-align: left">
													•
													{{
														$t('attendance.rotation.planDesign.deleteDuration')
													}}
												</li>
											</ul>
										</template>
										<div
											class="selectedCell"
											:style="{
												transform: isArabic ? 'rotateY(180deg)' : 'none',
											}"
											@click="handleDayClick(month, day)"
										>
											{{
												['Arrive_T', 'Leave_T'].includes(day.duration)
													? 'T'
													: day.duration
											}}
											<span
												v-if="
													(isAddComment &&
														editActiveIndex ===
															`${month.month}-edit-${day.day}`) ||
													day.note
												"
												>:
											</span>
										</div>
									</a-tooltip>
									<div
										class="drag_area"
										:title="$t('attendance.rotation.planDesign.addNote')"
									>
										<a-input
											v-if="
												isAddComment &&
												editActiveIndex === `${month.month}-edit-${day.day}`
											"
											v-model:value="day.note"
											:placeholder="
												$t('attendance.rotation.planDesign.addANote')
											"
											@click.stop
										></a-input>
										<div
											v-if="
												day.note.length &&
												editActiveIndex !== `${month.month}-edit-${day.day}`
											"
											class="note"
											:title="day.note"
										>
											{{ day.note }}
										</div>
									</div>
								</div>

								<!-- 下拉列表 -->
								<ul
									v-if="activeDayIndex == `${month.monthLabel}-day-${day.day}`"
									class="plan_list"
									:class="{ up: monthIndex > 3 }"
								>
									<li
										v-for="item in rotationPlanTypes"
										:key="item.value"
										class="plan_list_item"
										:class="{ active: item.value === day.duration }"
										@click="handlePlanTypeClick(day, item.value)"
									>
										{{ item.label }}
									</li>
								</ul>
							</div>
						</div>
					</div>
					<!-- 显示Rotation plan changed -->
					<template v-if="planChangedArr.length">
						<div
							v-for="(item, idx) in planChangedArr"
							:key="idx"
							class="planChangedItem"
							:style="{
								width: `${item.width}px`,
								left: `${item.left}px`,
								top: `${item.top}px`,
							}"
						>
							<a-tooltip
								color="#fff"
								:overlay-inner-style="{
									width: '371px',
								}"
							>
								<template #title>
									<div class="rotationPlanChanged">
										<div class="rotationPlanChanged-header">
											<span class="title">{{
												$t('attendance.rotation.planDesign.planChangedTitle')
											}}</span>
											<span class="time">{{ item.createTime || '-' }}</span>
										</div>
										<div class="rotationPlanChanged-operation">
											<span @click="openRequestDetail(item)">
												{{ $t('attendance.rotation.planDesign.viewDetails') }}
											</span>
										</div>
										<div class="rotationPlanChanged-content">
											<div
												v-for="(k, index) in item.changeHistory"
												:key="index"
												class="history-item"
											>
												{{ dayjs(k.beginDate).format('MM/DD/YYYY') }} -
												{{ dayjs(k.endDate).format('MM/DD/YYYY') }}, From
												<span :class="[`${k.from}`]">{{ k.from }}</span> to
												<span :class="[`${k.to}`]">{{ k.to }}</span>
											</div>
										</div>
									</div>
								</template>
								<div style="width: 100%; height: 100%"></div>
							</a-tooltip>
						</div>
					</template>
					<!-- 显示comment -->
					<template v-if="commentArr.length">
						<div
							v-for="(item, idx) in commentArr"
							:key="idx"
							class="planChangedItem"
							:style="{
								width: `${item.width}px`,
								left: `${item.left}px`,
								top: `${item.top}px`,
							}"
						>
							<a-tooltip
								color="#fff"
								:overlay-inner-style="{
									width: '297px',
								}"
								@open-change="comment = ''"
							>
								<template #title>
									<div class="commentBox">
										<div class="comment">
											<div
												v-for="(k, index) in item.commentList"
												:key="index"
												class="comment-item"
											>
												<div class="top">
													<span class="nameT">
														{{
															k.employeeName &&
															k.employeeName.slice(0, 1).toUpperCase()
														}}
													</span>
													<span class="username">{{ k.employeeName }}</span>
													<span class="time">{{
														formatTime(k.createTime)
													}}</span>
												</div>
												<div class="bottom">
													<div class="msg">
														{{ k.comment }}
													</div>
												</div>
											</div>
										</div>
										<div class="reply">
											<span class="nameT">
												{{ username.slice(0, 1).toUpperCase() }}
											</span>
											<div class="input">
												<a-input
													v-model:value="comment"
													placeholder="Reply"
													@click.stop
												></a-input>
												<svg-icon
													name="replyComment"
													width="17"
													height="14"
													class="icon"
													@click="replyComment(item)"
												></svg-icon>
											</div>
										</div>
									</div>
								</template>
								<div style="width: 100%; height: 100%"></div>
							</a-tooltip>
						</div>
					</template>
					<!-- 显示Overlap -->
					<template v-if="overlapArr.length && props.isB2B">
						<div
							v-for="(item, idx) in overlapArr"
							:key="idx"
							class="overlapAndGapItem"
							:style="{
								width: `${item.width}px`,
								left: `${item.left}px`,
								top: `${item.top}px`,
							}"
						>
							<svg-icon
								name="overlapAndGapErrorIcon"
								width="14.2"
								height="12px"
								class="icon"
							/>
							{{ $t('attendance.rotation.planDesign.overlap') }}
						</div>
					</template>
					<!-- 显示Gap -->
					<template v-if="gapArr.length && props.isB2B">
						<div
							v-for="(item, idx) in gapArr"
							:key="idx"
							class="overlapAndGapItem"
							:style="{
								width: `${item.width}px`,
								left: `${item.left}px`,
								top: `${item.top}px`,
							}"
						>
							<svg-icon
								name="overlapAndGapErrorIcon"
								width="14.2"
								height="12px"
								class="icon"
							/>
							{{ $t('attendance.rotation.planDesign.gap') }}
						</div>
					</template>
				</div>
			</div>
		</div>
	</div>
	<ChangeRequestDetailsModal
		v-if="open"
		v-model:open="open"
		:current-data="currentPlanChangedItem"
		:query-type="0"
	/>
</template>

<style scoped lang="scss">
.rotationPlanDesign {
	padding: 32px 46px;
	position: relative;
	// overflow-x: none;
	&-header {
		display: flex;
		flex-direction: column;
		margin-bottom: 12px;
		position: relative;
		.first-floor {
			display: flex;
			justify-content: space-between;
			margin-bottom: 16px;
			.title {
				font-size: 16px;
				font-weight: 600;
				color: #66809e;
			}
			.tips {
				display: flex;
				.tip-item {
					display: flex;
					align-items: center;
					margin-inline-end: 36px;
					&:last-child {
						margin-inline-end: 0;
					}
					.icon {
						width: 22px;
						height: 22px;
						border-radius: 50%;
						display: flex;
						align-items: center;
						justify-content: center;
						font-size: 12px;
						font-weight: 700;
						color: #66809e;
						background: rgba(102, 128, 158, 0.1);
						&.R {
							background: rgba(47, 138, 57, 0.15);
							color: #2d8a39;
						}
						&.T {
							background: rgba(238, 166, 0, 0.15);
							color: #eea600;
						}
					}
					.msg {
						margin-inline-start: 14px;
						font-size: 12px;
						font-weight: 470;
					}
				}
			}
		}
		.last-floor {
			margin-bottom: 16px;
			display: flex;
			justify-content: space-between;
			&-left {
				.total {
					margin-inline-end: 36px;
					font-size: 16px;
					.key {
						font-weight: 470;
						color: #66809e;
						padding-inline-end: 4px;
					}
					.value {
						font-weight: 600;
						color: #131211;
					}
				}
			}
			&-right {
				.autofill {
					color: #ff7a00;
					font-size: 12px;
					font-weight: 500;
					text-decoration: underline;
					cursor: pointer;
					&:hover {
						color: #cb6100;
					}
				}
			}
			&.detail {
				position: absolute;
				top: 4px;
			}
		}
		.overlap-tips {
			margin-bottom: 16px;
			font-size: 14px;
			color: #cb0000;
		}
	}
	&-content {
		display: flex;
		align-items: center;
		justify-content: space-around;
		overflow-x: auto; /* 水平滚动条 */
		direction: ltr !important;

		.gantt-container {
			width: 1576px;
			position: relative;
			&.read-only::after {
				content: '';
				position: absolute;
				width: 100%;
				height: 100%;
				z-index: 99;
				top: 0;
				bottom: 0;
			}
		}

		.gantt-header,
		.gantt-row {
			display: flex;
		}

		.gantt-header {
			.gantt-cell {
				width: 47px;
				height: 34px;
				color: #ff7a00;
				font-weight: 600;
				font-size: 12px;
				user-select: none;
			}
			.empty {
				width: 75px;
				height: 30px;
				padding-inline-end: 15px;
				box-sizing: content-box;
			}
		}

		.gantt-cell {
			display: flex;
			align-items: center;
			justify-content: center;
		}

		.gantt-body {
			position: relative;
			padding-top: 12px;
			&::after {
				content: '';
				position: absolute;
				width: 1px;
				height: 100%;
				background: #eaedf7;
				left: 89px;
				top: 0;
			}
			.planChangedItem {
				width: 47px;
				height: 34px;
				cursor: pointer;
				position: absolute;
				top: 14px;
				z-index: 2002;
				border: 1px dashed #cb0000;
				&::after {
					content: '';
					position: absolute;
					top: -1px;
					right: -1px;
					width: 0;
					height: 0;
					border-left: 8px solid transparent;
					border-top: 8px solid #cb0000;
				}
			}
			.overlapAndGapItem {
				width: 47px;
				height: 34px;
				position: absolute;
				top: 14px;
				z-index: 2002;
				background: rgba(203, 0, 0, 0.15);
				display: flex;
				align-items: center;
				justify-content: center;
				font-size: 12px;
				color: #cb0000;
				font-weight: bold;
				overflow: hidden;
				.icon {
					margin-inline-end: 4px;
				}
			}
		}

		.gantt-row {
			.gantt-cell {
				&.disabled {
					cursor: not-allowed !important;
				}
				&.month {
					width: 69px;
					height: 30px;
					font-weight: bold;
					font-size: 12px;
					font-weight: 600;
					justify-content: flex-end;
					user-select: none;
					padding: 14px 0;
					padding-inline-end: 20px;
					box-sizing: content-box;
				}
				&.day {
					box-sizing: content-box;
					margin: 12px 0;
					width: 47px;
					border-radius: 2px;
					cursor: pointer;
					position: relative;
					user-select: none;
					.week {
						position: absolute;
						top: -18px;
						color: #a7b1c2;
						font-size: 12px;
						font-weight: 600;
						width: 100%;
						display: flex;
						align-items: center;
						justify-content: center;
						cursor: initial;
					}
					&::after {
						content: '';
						position: absolute;
						right: 0;
						top: -25px;
						width: 1px;
						height: 120%;
						background-color: rgba(231, 235, 242, 0.5);
						height: 71px;
					}
					&:nth-child(4n + 1) {
						&::after {
							background-color: #eaedf7;
						}
					}
					.action {
						width: 100%;
						height: 100%;
						display: flex;
						.grid {
							width: 100%;
							height: 100%;
							text-align: center;
							.anticon {
								font-size: 12px;
								color: #66809e;
							}
							&.dropdown {
								display: flex;
								align-items: center;
								justify-content: center;
								&.active {
									background-color: #f0f3f6;
								}
							}
						}

						.duration {
							background-color: rgba(102, 128, 158, 0.1);
							color: #66809e;
							font-weight: 700;
							font-size: 13px;
							border-radius: 2px;
							display: flex;
							position: absolute;
							top: 0;
							left: 0;
							z-index: 3;
							border: 1px dashed transparent;
							&.active {
								border-color: #ff8a20;
							}

							.selectedCell {
								width: 47px;
								height: 100%;
								display: flex;
								align-items: center;
								justify-content: center;
								z-index: 4;
							}
							.drag_area {
								// padding: 4px;
								position: absolute;
								top: 0;
								left: 47px;
								height: 100%;
								text-align: left;
								.note {
									max-width: 90%;
									height: 100%;
									text-overflow: ellipsis;
									white-space: nowrap;
									display: inline-block;
									overflow: hidden;
									line-height: 32px;
								}
							}
							&.R {
								background-color: rgba(224, 238, 226, 0.8);
								color: #2d8a39;
								&::after {
									border-color: #2d8a39;
								}
							}
							&.Arrive_T,
							&.Leave_T {
								background-color: rgba(253, 242, 217, 0.8);
								color: #eea600;
								&::after {
									border-color: #eea600;
								}
							}

							&:hover,
							&:active {
								z-index: 100;
								background-color: rgba(102, 128, 158, 0.15);
								&.R {
									background-color: rgba(224, 238, 226, 1);
								}
								&.Arrive_T,
								&.Leave_T {
									background-color: rgba(253, 242, 217, 1);
								}
								&::after {
									display: block;
								}
							}
							&.hideAfter {
								&::after {
									display: none;
								}
							}
							&::after {
								content: '';
								width: 8px;
								height: 8px;
								border: 1px solid #66809e;
								position: absolute;
								border-radius: 50%;
								right: -4px;
								top: calc(15px - 4px);
								display: none;
								z-index: 4;
								background-color: #fff;
							}
						}
						.addCommentActive {
							border-color: #cb0000 !important;
							border-style: solid;
							background: #cb000026 !important;
							color: #cb0000 !important;
							.drag_area {
								display: flex;
								align-items: center;
								padding-inline-start: 10px;
								&::before {
									content: '';
									width: 2px;
									position: absolute;
									top: 6px;
									left: -3px;
									height: 18px;
									background: #ff7a00;
								}
								.ant-input {
									background: transparent;
									border: none;
									padding: 0;
									font-weight: 600;
									color: #66809e;
									border-radius: 0;
									font-size: 13px;
									&:focus {
										box-shadow: none;
									}
									&::placeholder {
										font-size: 13px;
										font-weight: 600;
										color: #66809e;
										opacity: 0.8;
									}
								}
							}
							&::after {
								border-color: #cb0000 !important;
							}
						}

						.hasComment {
							border-color: transparent;
							background: #cb000026 !important;
							color: #cb0000 !important;
							&::after {
								border-color: #cb0000 !important;
							}
						}

						.plan_list {
							position: absolute;
							left: 0;
							top: 30px;
							width: 100%;
							z-index: 1072;
							border-radius: 2px;
							background: #fff;
							padding: 0;
							padding-top: 2px;
							&.up {
								top: auto;
								bottom: 23px;
								left: auto;
							}
							&_item {
								width: 100%;
								height: 32px;
								display: flex;
								align-items: center;
								justify-content: center;
								font-size: 12px;
								background: #e8ecf0;
								color: #66809e;
								border-bottom: 1px solid #dae0e6;
								font-weight: 500;
								cursor: pointer;
								&:first-child {
									border-radius: 2px 2px 0 0;
								}
								&:last-child {
									border-radius: 0 0 2px 2px;
								}

								&:hover {
									background: #66809e;
									color: #fff;
								}
								&.active {
									background: #66809e;
									color: #fff;
								}
							}
						}
					}
				}
			}
		}
	}
}
</style>

<style lang="scss">
.rotationPlanChanged {
	padding: 10px 10px;
	&-header {
		margin-bottom: 10px;
		font-size: 12px;
		.title {
			color: #131211;
			font-weight: 700;
		}
		.time {
			padding-inline-start: 8px;
			color: #66809e;
		}
	}
	&-operation {
		color: #66809e;
		margin-bottom: 10px;
		span {
			text-decoration: underline;
			font-style: italic;
			cursor: pointer;
		}
	}
	&-content {
		.history-item {
			color: #131211;
			padding-bottom: 8px;
			span {
				padding: 0 2px;
				font-weight: 600;
				color: #66809e;
				&.Arrive_T,
				&.Leave_T {
					color: #eea600;
				}
				&.R {
					color: #2d8a39;
				}
			}
		}
	}
}

.commentBox {
	padding: 16px;
	border-radius: 8px;
	background: #fff;
	position: relative;
	.closeIcon {
		position: absolute;
		font-size: 12px;
		color: #272d37;
		top: 10px;
		right: 10px;
		cursor: pointer;
	}
	.nameT {
		width: 28px;
		height: 28px;
		background: #fff9df;
		border-radius: 50%;
		color: #eea600;
		font-weight: 700;
		font-size: 16px;
		display: flex;
		align-items: center;
		justify-content: center;
		margin-inline-end: 12px;
	}
	.comment {
		max-height: 150px;
		overflow-y: auto;
		margin-bottom: 12px;
		&::-webkit-scrollbar {
			width: 8px !important;
		}
		&-item {
			margin-bottom: 12px;
			&:last-child {
				margin-bottom: 0;
			}
			.top {
				display: flex;
				align-items: center;
				font-size: 12px;
				font-weight: 700;
				margin-bottom: 8x;
				.username {
					color: #131211;
					margin-inline-end: 8px;
				}
				.time {
					color: #66809e;
				}
			}
			.bottom {
				padding-inline-start: 40px;
				.msg {
					color: #131211;
					font-size: 12px;
					font-weight: 400;
					text-wrap: wrap;
					line-height: 20px;
				}
			}
		}
	}
	.reply {
		display: flex;
		align-items: center;
		.ant-input {
			background: transparent;
			border: none;
			padding: 0;
			font-weight: 600;
			color: #66809e;
			border-radius: 0;
			font-size: 13px;
			&:focus {
				box-shadow: none;
			}
			&::placeholder {
				font-size: 12px;
				font-weight: 400;
				color: #a7b1c2;
				opacity: 0.8;
			}
		}
		.input {
			flex: 1;
			display: flex;
			align-items: center;
			border: 1px solid #dae0e6;
			border-radius: 4px;
			padding: 4px 12px;
			.icon {
				cursor: pointer;
			}
		}
	}
}
</style>

原理其实跟我第一篇文章列表中日历差不多,只不过这个比较复杂。功能点也比较多。项目定制化非常强,发表几个代表性得东西。