OfficeSystem/components/MonthCalendar.vue
2025-11-04 17:10:40 +08:00

664 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>