feat(calendar): 添加日历组件并替换待办事项列表

重构待办事项列表为日历视图,添加@fullcalendar/core依赖
支持营期设置、日期选择和事件展示功能
This commit is contained in:
2025-08-25 15:47:19 +08:00
parent 0347da9cdc
commit 962b430a75
5 changed files with 1002 additions and 691 deletions

View File

@@ -0,0 +1,967 @@
<template>
<div class="calendar-container">
<!-- 头部导航 -->
<div class="calendar-header">
<button @click="previousMonth" class="nav-btn">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<h2 class="month-year">{{ currentYear }}{{ currentMonth + 1 }}</h2>
<div class="header-actions">
<button v-if="!shouldShowFinishCamp()" @click="showCampModal = true" class="camp-btn">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
设置营期
</button>
<button v-if="shouldShowFinishCamp()" @click="finishCamp" class="camp-btn finish-camp">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
</svg>
结束营期
</button>
<button @click="nextMonth" class="nav-btn">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button>
</div>
</div>
<!-- 星期标题 -->
<div class="weekdays">
<div v-for="day in weekdays" :key="day" class="weekday">
{{ day }}
</div>
</div>
<!-- 日期网格 -->
<div class="calendar-grid">
<div
v-for="date in calendarDates"
:key="date.key"
:class="[
'date-cell',
{
'other-month': !date.isCurrentMonth,
'today': date.isToday,
'selected': date.isSelected,
'has-event': date.hasEvent
}
]"
@click="selectDate(date, $event)"
>
<span class="date-number">{{ date.day }}</span>
<div v-if="date.hasEvent && !isRestDay(date.dateStr)" class="event-dot" :class="getEventTypeClass(date.dateStr)"></div>
<span v-if="isRestDay(date.dateStr)" class="rest-text"></span>
</div>
</div>
<!-- 悬浮日期信息 -->
<div
v-if="showTooltip && selectedDate"
class="date-tooltip"
:style="{ left: tooltipPosition.x + 'px', top: tooltipPosition.y + 'px' }"
>
<h3>{{ formatSelectedDate }}</h3>
<div v-if="selectedDateEvents.length > 0" class="events-list">
<div v-for="event in selectedDateEvents" :key="event.id" class="event-item">
<span class="event-title">{{ event.title }}</span>
<span class="event-desc">{{ event.description }}</span>
</div>
</div>
<div v-else class="no-events">
暂无日程安排
</div>
</div>
<!-- 设置营期弹窗 -->
<div v-if="showCampModal" class="modal-overlay" @click="showCampModal = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>设置营期</h3>
<button @click="showCampModal = false" class="close-btn">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="campStartDate">营期开始时间</label>
<input
type="date"
id="campStartDate"
v-model="campStartDate"
class="form-input"
/>
</div>
<div class="form-group">
<label for="campDays">接数据天数</label>
<input
type="number"
id="campDays"
v-model="campDays"
min="1"
max="365"
class="form-input"
placeholder="请输入天数"
/>
</div>
</div>
<div class="modal-footer">
<button @click="showCampModal = false" class="cancel-btn">取消</button>
<button @click="saveCampSettings" class="save-btn">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import {getCampPeriodAdmin} from '@/api/secondTop.js'
// 响应式数据
const currentYear = ref(new Date().getFullYear());
const currentMonth = ref(new Date().getMonth());
const selectedDate = ref(null);
const today = new Date();
const showTooltip = ref(false);
const tooltipPosition = ref({ x: 0, y: 0 });
// 营期设置相关
const showCampModal = ref(false);
const campStartDate = ref('');
const campDays = ref();
const isCampFinished = ref(false);
// 获取本地时区的今日日期字符串
const getTodayString = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const todayString = getTodayString();
// 星期标题
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
// 示例事件数据
const events = ref([]);
// 计算属性:当前月份的日期数组
const calendarDates = computed(() => {
const dates = [];
const firstDay = new Date(currentYear.value, currentMonth.value, 1);
const lastDay = new Date(currentYear.value, currentMonth.value + 1, 0);
const startDate = new Date(firstDay);
// 调整到周日开始
startDate.setDate(startDate.getDate() - startDate.getDay());
// 生成6周的日期
for (let i = 0; i < 42; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const isCurrentMonth = date.getMonth() === currentMonth.value;
const isToday = dateStr === todayString;
const hasEvent = events.value.some(event => event.date === dateStr);
dates.push({
key: `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
date: date,
day: date.getDate(),
dateStr: dateStr,
isCurrentMonth: isCurrentMonth,
isToday: isToday,
isSelected: selectedDate.value && selectedDate.value.dateStr === dateStr,
hasEvent: hasEvent
});
}
return dates;
});
// 计算属性:格式化选中日期
const formatSelectedDate = computed(() => {
if (!selectedDate.value) return '';
const date = selectedDate.value.date;
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
});
// 计算属性:选中日期的事件
const selectedDateEvents = computed(() => {
if (!selectedDate.value) return [];
return events.value.filter(event => event.date === selectedDate.value.dateStr);
});
// 方法:上一月
const previousMonth = () => {
if (currentMonth.value === 0) {
currentMonth.value = 11;
currentYear.value--;
} else {
currentMonth.value--;
}
};
// 方法:下一月
const nextMonth = () => {
if (currentMonth.value === 11) {
currentMonth.value = 0;
currentYear.value++;
} else {
currentMonth.value++;
}
};
// 方法:选择日期
const selectDate = (date, event) => {
selectedDate.value = date;
if (event) {
tooltipPosition.value = {
x: event.clientX + 10,
y: event.clientY - 10
};
showTooltip.value = true;
// 3秒后自动隐藏
setTimeout(() => {
showTooltip.value = false;
}, 3000);
}
};
// 方法:隐藏悬浮框
const hideTooltip = () => {
showTooltip.value = false;
};
// 方法:保存营期设置
const saveCampSettings = async () => {
if (!campStartDate.value) {
alert('请选择营期开始时间');
return;
}
if (!campDays.value || campDays.value < 1) {
alert('请输入有效的天数');
return;
}
try {
// 调用API设置营期参数并获取营期安排
const result = await CenterCampPeriodAdmin({
receipt_data_start_time: campStartDate.value,
receipt_data_time: campDays.value.toString()
});
if (result && result.data) {
showCampModal.value = false;
}
} catch (error) {
console.error('保存营期设置失败:', error);
}
};
// 方法:生成营期日程安排
const generateCampSchedule = () => {
const startDate = new Date(campStartDate.value);
const dataDays = campDays.value || 2; // 接数据天数默认2天
let currentDate = new Date(startDate);
let eventId = events.value.length + 1;
// 清除之前的营期相关事件
events.value = events.value.filter(event => !event.isCampEvent);
// 添加接数据日程
for (let i = 0; i < dataDays; i++) {
const dateStr = formatDateToString(currentDate);
events.value.push({
id: eventId++,
date: dateStr,
title: `接数据 第${i + 1}`,
description: '营期数据接收阶段',
isCampEvent: true,
type: 'data'
});
currentDate.setDate(currentDate.getDate() + 1);
}
// 添加课程日程
const courses = ['课1', '课2', '课3', '课4'];
courses.forEach((course, index) => {
const dateStr = formatDateToString(currentDate);
events.value.push({
id: eventId++,
date: dateStr,
title: course,
description: `${course}`,
isCampEvent: true,
type: 'course'
});
currentDate.setDate(currentDate.getDate() + 1);
});
// 添加休息日程
const restDateStr = formatDateToString(currentDate);
events.value.push({
id: eventId++,
date: restDateStr,
title: '休息',
description: '营期休息日',
isCampEvent: true,
type: 'rest'
});
};
// 辅助方法:格式化日期为字符串
const formatDateToString = (date) => {
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}`;
};
// 方法:获取事件类型样式类
const getEventTypeClass = (dateStr) => {
const dayEvents = events.value.filter(event => event.date === dateStr && event.isCampEvent);
if (dayEvents.length === 0) return '';
const eventType = dayEvents[0].type;
switch (eventType) {
case 'data':
return 'event-data';
case 'course':
return 'event-course';
case 'rest':
return 'event-rest';
default:
return '';
}
};
// 方法:判断是否为休息日
const isRestDay = (dateStr) => {
const dayEvents = events.value.filter(event => event.date === dateStr && event.isCampEvent && event.type === 'rest');
return dayEvents.length > 0;
};
// 方法:检查营期是否应该结束(休息日已过)
const shouldShowFinishCamp = () => {
if (isCampFinished.value) return false;
const today = formatDateToString(new Date());
const restDayEvents = events.value.filter(event => event.isCampEvent && event.type === 'rest');
if (restDayEvents.length === 0) return false;
// 检查是否有休息日已经过去
const todayDate = new Date(today);
for (const restEvent of restDayEvents) {
const restDate = new Date(restEvent.date);
if (todayDate > restDate) {
return true;
}
}
return false;
};
// 方法:结束营期
const finishCamp = async () => {
try {
isCampFinished.value = true;
await CenterCampPeriodAdmin({
is_camp_finish: true
});
} catch (error) {
console.error('结束营期失败:', error);
isCampFinished.value = false;
}
};
// 路由实例
const router = useRouter();
// 用户store实例
const userStore = useUserStore();
// 获取通用请求参数的函数
const getRequestParams = () => {
const params = {}
// 只从路由参数获取
const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
// 如果路由有参数,使用路由参数
if (routeUserLevel) {
params.user_level = routeUserLevel.toString()
}
if (routeUserName) {
params.user_name = routeUserName
}
return params
}
// 判断是否为路由导航(有路由参数)
const isRouteNavigation = computed(() => {
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
return !!routeUserName
})
// 获取和设置当前营期阶段 getCampPeriodAdmin
async function CenterCampPeriodAdmin(data = {}) {
const params = getRequestParams()
const hasParams = params.user_name
// 根据传入的参数决定传递哪些数据
let Finsh = {}
if (data.is_camp_finish !== undefined) {
// 结束营期时只传递is_camp_finish参数
Finsh.is_camp_finish = data.is_camp_finish
} else if (data.receipt_data_start_time && data.receipt_data_time) {
// 设置营期时,传递开始时间和天数参数
Finsh.receipt_data_start_time = data.receipt_data_start_time
Finsh.receipt_data_time = data.receipt_data_time
} else {
// 兼容原有逻辑,使用全局变量
if (isCampFinished.value) {
Finsh.is_camp_finish = isCampFinished.value
} else if (campStartDate.value && campDays.value) {
// 只有在有营期设置数据时才传递参数
Finsh.receipt_data_start_time = campStartDate.value
Finsh.receipt_data_time = campDays.value.toString()
}
// 如果没有营期设置数据Finsh 保持为空对象,用于获取现有数据
}
console.log('Finsh', Finsh)
console.log('params', params)
const res = await getCampPeriodAdmin(hasParams ? {...params, ...Finsh} : Finsh)
// 如果获取到营期数据,映射到日历中
if (res && res.data && res.data.camp_period) {
mapCampPeriodToCalendar(res.data.camp_period);
}
return res
}
// 方法:将营期时间安排映射到日历
const mapCampPeriodToCalendar = (campPeriod) => {
// 清除之前的营期相关事件
events.value = events.value.filter(event => !event.isCampEvent);
let eventId = events.value.length + 1;
// 解析接数据时间
if (campPeriod.receipt_data_time) {
const dataPeriod = parseDateRange(campPeriod.receipt_data_time);
for (let date = new Date(dataPeriod.start); date <= dataPeriod.end; date.setDate(date.getDate() + 1)) {
const dateStr = formatDateToString(new Date(date));
events.value.push({
id: eventId++,
date: dateStr,
title: '接数据',
description: '营期数据接收阶段',
isCampEvent: true,
type: 'data'
});
}
}
// 解析课程时间
const courses = [
{ key: 'class_one', title: '课1' },
{ key: 'class_two', title: '课2' },
{ key: 'class_three', title: '课3' },
{ key: 'class_four', title: '课4' }
];
let lastCourseEndDate = null;
courses.forEach(course => {
if (campPeriod[course.key]) {
const coursePeriod = parseDateRange(campPeriod[course.key]);
const dateStr = formatDateToString(coursePeriod.start);
events.value.push({
id: eventId++,
date: dateStr,
title: course.title,
description: `营期${course.title}阶段`,
isCampEvent: true,
type: 'course'
});
// 记录最后一个课程的结束日期
if (course.key === 'class_four') {
lastCourseEndDate = coursePeriod.end;
}
}
});
// 在课4之后添加休息日
if (lastCourseEndDate) {
const restDate = new Date(lastCourseEndDate);
restDate.setDate(restDate.getDate() + 1);
const restDateStr = formatDateToString(restDate);
events.value.push({
id: eventId++,
date: restDateStr,
title: '休息',
description: '营期休息日',
isCampEvent: true,
type: 'rest'
});
}
};
// 辅助方法:解析日期范围字符串
const parseDateRange = (dateRangeStr) => {
// 解析格式: "2025-08-15 00:00:00 to 2025-08-16 23:59:59"
const [startStr, endStr] = dateRangeStr.split(' to ');
return {
start: new Date(startStr.split(' ')[0]),
end: new Date(endStr.split(' ')[0])
};
};
// 组件挂载时选中今天并获取营期数据
onMounted(async () => {
const todayDate = calendarDates.value.find(date => date.isToday);
if (todayDate) {
selectedDate.value = todayDate;
}
// 获取现有的营期数据
try {
await CenterCampPeriodAdmin();
} catch (error) {
console.error('获取营期数据失败:', error);
}
});
</script>
<style scoped>
.calendar-container {
max-width: 100%;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 24px;
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
color: white;
}
.month-year {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.weekday {
padding: 12px 8px;
text-align: center;
font-weight: 600;
font-size: 14px;
color: #6c757d;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
background: transparent;
}
.date-cell {
position: relative;
min-height: 44px;
background: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.date-cell:hover {
background: #f8f9fa;
}
.date-cell.other-month {
color: #adb5bd;
background: #f8f9fa;
}
.date-cell.today {
background: #e3f2fd;
}
.date-cell.today .date-number {
background: #2196f3;
color: white;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.date-cell.selected {
border-color: #667eea;
background: #f0f4ff;
}
.date-number {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
}
.event-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff6b6b;
position: absolute;
bottom: 8px;
}
.date-cell.has-event .event-dot {
background: #4ecdc4;
}
.date-tooltip {
position: fixed;
z-index: 1000;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 16px;
max-width: 280px;
min-width: 200px;
border: 1px solid #e9ecef;
pointer-events: none;
}
.date-tooltip h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #495057;
border-bottom: 1px solid #e9ecef;
padding-bottom: 8px;
}
.events-list {
space-y: 8px;
}
.event-item {
display: flex;
flex-direction: column;
padding: 12px;
background: white;
border-radius: 8px;
margin-bottom: 8px;
}
.event-title {
font-weight: 600;
color: #495057;
margin-bottom: 4px;
}
.event-desc {
font-size: 14px;
color: #6c757d;
}
.no-events {
color: #adb5bd;
font-style: italic;
text-align: center;
padding: 20px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.calendar-header {
padding: 16px 20px;
}
.month-year {
font-size: 16px;
}
.nav-btn {
width: 32px;
height: 32px;
}
.date-cell {
min-height: 50px;
}
.weekday {
padding: 10px 6px;
font-size: 12px;
}
.date-tooltip {
max-width: 250px;
min-width: 180px;
padding: 12px;
}
.date-tooltip h3 {
font-size: 13px;
}
}
/* 头部操作区域 */
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 设置营期按钮 */
.camp-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: linear-gradient(135deg, #28a745, #20c997);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.camp-btn:hover {
background: linear-gradient(135deg, #218838, #1ea085);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3);
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e9ecef;
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #495057;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: #6c757d;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #f8f9fa;
color: #495057;
}
.modal-body {
padding: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #495057;
font-size: 14px;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
}
.cancel-btn, .save-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-btn {
background: #6c757d;
color: white;
}
.cancel-btn:hover {
background: #5a6268;
}
.save-btn {
background: linear-gradient(135deg, #4a90e2, #357abd);
color: white;
}
.save-btn:hover {
background: linear-gradient(135deg, #357abd, #2968a3);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(74, 144, 226, 0.3);
}
.finish-camp {
background: linear-gradient(135deg, #e74c3c, #c0392b);
color: white;
}
.finish-camp:hover {
background: linear-gradient(135deg, #c0392b, #a93226);
transform: translateY(-1px);
}
/* 营期事件类型样式 */
.event-dot.event-data {
background: linear-gradient(135deg, #9c27b0, #7b1fa2);
box-shadow: 0 2px 4px rgba(156, 39, 176, 0.3);
}
.event-dot.event-course {
background: linear-gradient(135deg, #fd7e14, #e55a00);
box-shadow: 0 2px 4px rgba(253, 126, 20, 0.3);
}
.event-dot.event-rest {
background: linear-gradient(135deg, #28a745, #1e7e34);
box-shadow: 0 2px 4px rgba(40, 167, 69, 0.3);
}
/* 休息日文字样式 */
.rest-text {
position: absolute;
bottom: 2px;
right: 2px;
font-size: 10px;
font-weight: bold;
color: #28a745;
background: rgba(40, 167, 69, 0.1);
border-radius: 2px;
padding: 1px 3px;
line-height: 1;
}
</style>