664 lines
15 KiB
Vue
664 lines
15 KiB
Vue
<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-6,0是星期日)
|
||
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>
|
||
|