OfficeSystem/components/MonthCalendar.vue

664 lines
15 KiB
Vue
Raw Normal View History

2025-11-04 17:10:40 +08:00
<template>
<view class="month-calendar-container">
<!-- 日期选择器头部 -->
<view class="calendar-header" @click="toggleCalendar">
<view class="date-display">
<text class="year-month">{{ displayYearMonth }}</text>
<text class="day">{{ displayDay }}</text>
<text class="weekday">{{ displayWeekday }}</text>
</view>
<view class="event-count" v-if="eventCount > 0">
<text>日程数{{ eventCount }}</text>
</view>
<view class="arrow-icon" :class="{ 'rotate': isExpanded }">
<text></text>
</view>
</view>
<!-- 日历下拉区域 -->
<view class="calendar-dropdown" :class="{ 'expanded': isExpanded }">
<!-- 月份切换栏 -->
<view class="month-header">
<view class="month-nav-btn" @click="prevMonth">
<text></text>
</view>
<view class="month-title">{{ currentYear }}{{ currentMonth }}</view>
<view class="month-nav-btn" @click="nextMonth">
<text></text>
</view>
</view>
<!-- 星期标题 -->
<view class="weekdays">
<view class="weekday-item" v-for="day in weekdays" :key="day">{{ day }}</view>
</view>
<!-- 日历滑动容器 -->
<view
class="calendar-swipe-container"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<view
class="calendar-wrapper"
:style="{
transform: `translateX(${translateX}px)`,
transition: isAnimating ? 'transform 0.3s ease-out' : 'none'
}"
>
<!-- 上一个月 -->
<view class="calendar-month">
<view
class="calendar-day"
v-for="(dayObj, index) in prevMonthDays"
:key="`prev-${index}`"
:class="{
'other-month': !dayObj.isCurrentMonth,
'today': isToday(dayObj),
'selected': isSelected(dayObj),
'has-event': hasEvent(dayObj)
}"
@click="selectDate(dayObj)"
>
<text class="day-number">{{ dayObj.day }}</text>
<view class="event-dot" v-if="hasEvent(dayObj)"></view>
</view>
</view>
<!-- 当前月 -->
<view class="calendar-month">
<view
class="calendar-day"
v-for="(dayObj, index) in currentMonthDays"
:key="`current-${index}`"
:class="{
'other-month': !dayObj.isCurrentMonth,
'today': isToday(dayObj),
'selected': isSelected(dayObj),
'has-event': hasEvent(dayObj)
}"
@click="selectDate(dayObj)"
>
<text class="day-number">{{ dayObj.day }}</text>
<view class="event-dot" v-if="hasEvent(dayObj)"></view>
</view>
</view>
<!-- 下一个月 -->
<view class="calendar-month">
<view
class="calendar-day"
v-for="(dayObj, index) in nextMonthDays"
:key="`next-${index}`"
:class="{
'other-month': !dayObj.isCurrentMonth,
'today': isToday(dayObj),
'selected': isSelected(dayObj),
'has-event': hasEvent(dayObj)
}"
@click="selectDate(dayObj)"
>
<text class="day-number">{{ dayObj.day }}</text>
<view class="event-dot" v-if="hasEvent(dayObj)"></view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
selectedDate: {
type: String,
default: () => new Date().toISOString().slice(0, 10)
},
events: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['change']);
// 展开/收起状态
const isExpanded = ref(false);
// 当前显示的年月
const currentYear = ref(new Date().getFullYear());
const currentMonth = ref(new Date().getMonth() + 1);
// 星期标题
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
// 滑动相关
const touchStartX = ref(0);
const screenWidth = ref(375);
const translateX = ref(0);
const baseTranslateX = ref(0);
const isAnimating = ref(false);
const isDragging = ref(false);
// 初始化屏幕宽度
const initScreenWidth = () => {
uni.getSystemInfo({
success: (res) => {
screenWidth.value = res.windowWidth || res.screenWidth || 375;
}
});
};
// 获取月份的第一天是星期几0-60是星期日
const getFirstDayOfMonth = (year, month) => {
return new Date(year, month - 1, 1).getDay();
};
// 获取月份的天数
const getDaysInMonth = (year, month) => {
return new Date(year, month, 0).getDate();
};
// 生成月份的日期数组(包含前后月份的填充日期)
const generateMonthDays = (year, month) => {
const firstDay = getFirstDayOfMonth(year, month);
const daysInMonth = getDaysInMonth(year, month);
const days = [];
// 计算上个月的最后几天
const prevMonth = month === 1 ? 12 : month - 1;
const prevYear = month === 1 ? year - 1 : year;
const prevMonthDays = getDaysInMonth(prevYear, prevMonth);
// 填充前面月份的日期(从最后几天开始)
if (firstDay > 0) {
for (let i = firstDay - 1; i >= 0; i--) {
days.push({
day: prevMonthDays - i,
year: prevYear,
month: prevMonth,
isCurrentMonth: false
});
}
}
// 填充当前月份的日期
for (let i = 1; i <= daysInMonth; i++) {
days.push({
day: i,
year: year,
month: month,
isCurrentMonth: true
});
}
// 填充后面月份的日期确保有6行即42个格子
const totalDays = days.length;
const remainingDays = 42 - totalDays; // 6行 * 7列 = 42
const nextMonth = month === 12 ? 1 : month + 1;
const nextYear = month === 12 ? year + 1 : year;
for (let i = 1; i <= remainingDays; i++) {
days.push({
day: i,
year: nextYear,
month: nextMonth,
isCurrentMonth: false
});
}
return days;
};
// 计算当前月份的日期
const currentMonthDays = computed(() => {
return generateMonthDays(currentYear.value, currentMonth.value);
});
// 计算上一个月
const prevMonthYear = computed(() => {
if (currentMonth.value === 1) {
return currentYear.value - 1;
}
return currentYear.value;
});
const prevMonthMonth = computed(() => {
if (currentMonth.value === 1) {
return 12;
}
return currentMonth.value - 1;
});
const prevMonthDays = computed(() => {
return generateMonthDays(prevMonthYear.value, prevMonthMonth.value);
});
// 计算下一个月
const nextMonthYear = computed(() => {
if (currentMonth.value === 12) {
return currentYear.value + 1;
}
return currentYear.value;
});
const nextMonthMonth = computed(() => {
if (currentMonth.value === 12) {
return 1;
}
return currentMonth.value + 1;
});
const nextMonthDays = computed(() => {
return generateMonthDays(nextMonthYear.value, nextMonthMonth.value);
});
// 格式化日期为 YYYY-MM-DD
const formatDate = (year, month, day) => {
const m = String(month).padStart(2, '0');
const d = String(day).padStart(2, '0');
return `${year}-${m}-${d}`;
};
// 判断是否是今天
const isToday = (dayObj) => {
if (!dayObj || !dayObj.day) return false;
const today = new Date();
return (
dayObj.year === today.getFullYear() &&
dayObj.month === today.getMonth() + 1 &&
dayObj.day === today.getDate()
);
};
// 判断是否被选中
const isSelected = (dayObj) => {
if (!dayObj || !dayObj.day) return false;
const selected = new Date(props.selectedDate);
return (
dayObj.year === selected.getFullYear() &&
dayObj.month === selected.getMonth() + 1 &&
dayObj.day === selected.getDate()
);
};
// 判断是否有事件
const hasEvent = (dayObj) => {
if (!dayObj || !dayObj.day) return false;
const dateStr = formatDate(dayObj.year, dayObj.month, dayObj.day);
return props.events.some(event => event.date === dateStr);
};
// 选择日期
const selectDate = (dayObj) => {
if (!dayObj || !dayObj.day) return;
const dateStr = formatDate(dayObj.year, dayObj.month, dayObj.day);
emit('change', dateStr);
// 选择后不收起日历
// setTimeout(() => {
// isExpanded.value = false;
// }, 200);
};
// 切换日历展开/收起
const toggleCalendar = () => {
isExpanded.value = !isExpanded.value;
if (isExpanded.value) {
initScreenWidth();
// 同步当前显示月份到选中日期
const selected = new Date(props.selectedDate);
currentYear.value = selected.getFullYear();
currentMonth.value = selected.getMonth() + 1;
translateX.value = -screenWidth.value;
baseTranslateX.value = -screenWidth.value;
}
};
// 切换到上一个月
const prevMonth = () => {
if (currentMonth.value === 1) {
currentYear.value -= 1;
currentMonth.value = 12;
} else {
currentMonth.value -= 1;
}
translateX.value = -screenWidth.value;
};
// 切换到下一个月
const nextMonth = () => {
if (currentMonth.value === 12) {
currentYear.value += 1;
currentMonth.value = 1;
} else {
currentMonth.value += 1;
}
translateX.value = -screenWidth.value;
};
// 触摸开始
const handleTouchStart = (e) => {
if (isAnimating.value) return;
const touch = e.touches[0];
touchStartX.value = touch.clientX;
isDragging.value = true;
baseTranslateX.value = translateX.value;
};
// 触摸移动
const handleTouchMove = (e) => {
if (!isDragging.value || isAnimating.value) return;
const touch = e.touches[0];
const deltaX = touch.clientX - touchStartX.value;
translateX.value = baseTranslateX.value + deltaX;
// 限制滑动范围
const minTranslate = -screenWidth.value * 2;
const maxTranslate = 0;
translateX.value = Math.max(minTranslate, Math.min(maxTranslate, translateX.value));
};
// 触摸结束
const handleTouchEnd = (e) => {
if (!isDragging.value || isAnimating.value) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - touchStartX.value;
const minSwipeDistance = screenWidth.value * 0.2;
isDragging.value = false;
if (Math.abs(deltaX) > minSwipeDistance) {
if (deltaX > 0) {
// 向左滑动,显示上一个月
slideToPrevMonth();
} else {
// 向右滑动,显示下一个月
slideToNextMonth();
}
} else {
// 回到中间位置
resetToCenter();
}
};
// 重置到中心位置
const resetToCenter = () => {
isAnimating.value = true;
translateX.value = -screenWidth.value;
setTimeout(() => {
isAnimating.value = false;
}, 300);
};
// 滑动到上一个月
const slideToPrevMonth = () => {
isAnimating.value = true;
translateX.value = -screenWidth.value * 2;
setTimeout(() => {
isAnimating.value = false;
prevMonth();
setTimeout(() => {
translateX.value = -screenWidth.value;
baseTranslateX.value = -screenWidth.value;
}, 0);
}, 300);
};
// 滑动到下一个月
const slideToNextMonth = () => {
isAnimating.value = true;
translateX.value = 0;
setTimeout(() => {
isAnimating.value = false;
nextMonth();
setTimeout(() => {
translateX.value = -screenWidth.value;
baseTranslateX.value = -screenWidth.value;
}, 0);
}, 300);
};
// 显示的年月日
const displayYearMonth = computed(() => {
const date = new Date(props.selectedDate);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
});
const displayDay = computed(() => {
const date = new Date(props.selectedDate);
return String(date.getDate()).padStart(2, '0');
});
const displayWeekday = computed(() => {
const date = new Date(props.selectedDate);
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
return `星期${weekdays[date.getDay()]}`;
});
// 日程数量
const eventCount = computed(() => {
return props.events.filter(e => e.date === props.selectedDate).length;
});
// 监听选中日期变化,同步月份显示
watch(() => props.selectedDate, (newDate) => {
if (!isExpanded.value) {
const selected = new Date(newDate);
currentYear.value = selected.getFullYear();
currentMonth.value = selected.getMonth() + 1;
}
}, { immediate: true });
// 初始化
initScreenWidth();
</script>
<style scoped lang="scss">
.month-calendar-container {
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 30rpx;
background: #fff;
cursor: pointer;
}
.date-display {
flex: 1;
display: flex;
align-items: center;
gap: 10rpx;
}
.year-month {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.day {
font-size: 32rpx;
font-weight: 600;
color: #2885ff;
}
.weekday {
font-size: 24rpx;
color: #999;
}
.event-count {
font-size: 24rpx;
color: #666;
margin-right: 20rpx;
}
.arrow-icon {
font-size: 24rpx;
color: #999;
transition: transform 0.3s;
&.rotate {
transform: rotate(180deg);
}
}
.calendar-dropdown {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
&.expanded {
max-height: 800rpx;
overflow: visible;
}
}
.month-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 30rpx;
border-bottom: 1px solid #f0f0f0;
}
.month-nav-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: #666;
cursor: pointer;
&:active {
background: #f5f5f5;
border-radius: 50%;
}
}
.month-title {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.weekdays {
display: flex;
padding: 20rpx 0;
border-bottom: 1px solid #f0f0f0;
}
.weekday-item {
flex: 1;
text-align: center;
font-size: 24rpx;
color: #666;
}
.calendar-swipe-container {
position: relative;
width: 100%;
overflow: hidden;
touch-action: pan-x;
}
.calendar-wrapper {
display: flex;
width: 300%;
will-change: transform;
}
.calendar-month {
flex: 0 0 33.333%;
width: 33.333%;
display: flex;
flex-wrap: wrap;
padding: 20rpx 0;
}
.calendar-day {
flex: 0 0 calc(100% / 7);
width: calc(100% / 7);
height: 80rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
&:active {
background: #f5f5f5;
border-radius: 50%;
}
&.other-month {
.day-number {
color: #ddd;
}
}
&.today {
.day-number {
color: #2885ff;
font-weight: 600;
}
}
&.selected {
.day-number {
color: #fff;
background: #2885ff;
border-radius: 50%;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
&.has-event {
&::after {
content: '';
position: absolute;
bottom: 8rpx;
width: 8rpx;
height: 8rpx;
background: #2885ff;
border-radius: 50%;
}
}
}
.day-number {
font-size: 28rpx;
color: #333;
}
.event-dot {
position: absolute;
bottom: 8rpx;
width: 8rpx;
height: 8rpx;
background: #2885ff;
border-radius: 50%;
}
</style>