自定义日历

This commit is contained in:
WindowBird 2025-11-04 17:10:40 +08:00
parent a364931dc7
commit 44c8d503c2
4 changed files with 686 additions and 70 deletions

View File

@ -0,0 +1,663 @@
<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
});
}
// 642
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>

View File

@ -11,6 +11,7 @@
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "办公管理"
}
},
{

View File

@ -253,7 +253,7 @@ const formData = ref({
allDay: false,
repeat: 'none',
description: '',
color: '#0079FE',
color: '#B3D9FF',
reminder: 15 //
});
@ -284,8 +284,12 @@ const endDateCalendar = ref(null);
//
const colorOptions = [
'#0079FE', '#FC902A', '#FF505D', '#18B48A',
'#FCBF28', '#8883F0',
'#B3D9FF', //
'#FFE8CC', //
'#FFD6D9', //
'#D4F3E8', // 绿
'#FFF4CC', //
'#E6E5FC', //
];
//

View File

@ -3,11 +3,12 @@
<uv-tabs :list="topTabs" @click="clickTab"></uv-tabs>
<!-- 内容区域 -->
<view class="content-wrapper">
<view>
<uv-calendar ref="calendar" mode="single" @confirm="handleConfirm" ></uv-calendar>
<button @click="openCalendar">{{ selectedDate }},日程数{{ eventsInDay ? eventsInDay.length : 0 }}</button>
</view>
<!-- 月份日历组件 -->
<MonthCalendar
:selected-date="selectedDate"
:events="allEvents"
@change="handleDateChange"
/>
<view
class="swipe-container"
@ -58,6 +59,7 @@ import { onShow } from '@dcloudio/uni-app';
import TimeTable from '@/components/TimeTable.vue';
import FabPlus from '@/components/FabPlus.vue';
import AddEventModal from '@/components/AddEventModal.vue';
import MonthCalendar from '@/components/MonthCalendar.vue';
// tabs
const topTabs = [
@ -132,65 +134,11 @@ watch(selectedDate, (newDate, oldDate) => {
console.log('eventsInDay 新值:', eventsInDay.value);
}, { immediate: true });
const calendar = ref(null)
// YYYY-MM-DD
function formatDateToYYYYMMDD(dateInput) {
if (!dateInput) return '';
let dateStr = '';
// YYYY-MM-DD
if (typeof dateInput === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateInput)) {
return dateInput;
}
//
if (typeof dateInput === 'string') {
dateStr = dateInput;
} else if (dateInput?.date) {
dateStr = dateInput.date;
} else if (dateInput?.value) {
dateStr = dateInput.value;
} else if (Array.isArray(dateInput) && dateInput.length > 0) {
dateStr = typeof dateInput[0] === 'string' ? dateInput[0] : (dateInput[0]?.date || dateInput[0]?.value || '');
}
if (!dateStr) return '';
//
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
// YYYY-MM-DD
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// YYYY-MM-DD new Date 使
if (/^\d{4}-\d{2}-\d{2}/.test(dateStr)) {
return dateStr.slice(0, 10);
}
return '';
}
//
const openCalendar = () => {
if (calendar.value) {
calendar.value.open()
}
}
// confirm
const handleConfirm = (e) => {
console.log('日历 confirm 事件:', e, typeof e);
const formattedDate = formatDateToYYYYMMDD(e);
if (formattedDate) {
selectedDate.value = formattedDate;
console.log('通过 confirm 更新选择日期:', selectedDate.value);
console.log('过滤后的事件数:', eventsInDay.value.length);
}
//
const handleDateChange = (dateStr) => {
selectedDate.value = dateStr;
console.log('通过日历组件更新选择日期:', selectedDate.value);
console.log('过滤后的事件数:', eventsInDay.value.length);
}
// /