You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

728 lines
23 KiB

4 months ago
<template>
<div class="admin-calendar">
<!-- 顶部操作区 -->
<div class="admin-header">
<el-button type="success" icon="el-icon-plus" @click="openCreateModal('add')"></el-button>
</div>
<!-- 日历预览区 -->
<div class="admin-main">
<div class="calendar-panel">
<div class="calendar-wrapper">
<el-calendar v-model="calendarDate" :first-day-of-week="7">
<template slot="dateCell" slot-scope="{date}">
<div class="cell-content">
<span class="date-number">{{ date.getDate() }}</span>
<div class="event-list">
<div
v-for="ev in eventsForDate(date)"
:key="ev._id"
:class="['event-item', getEventClass(ev, date)]"
3 months ago
:style="getEventInlineStyle(ev)"
4 months ago
:title="getEventTooltip(ev, date)"
@click.stop="openCreateModal('editor', ev.id)"
>
<span class="event-title">{{ ev.title }}</span>
</div>
</div>
</div>
</template>
</el-calendar>
<!-- 连续事件覆盖层 -->
<div class="continuous-events-overlay">
<div
v-for="event in getContinuousEvents()"
3 months ago
:key="`continuous-${event.id}-${event.weekStartISO}-${event.startCol}`"
4 months ago
:class="['continuous-event', `event-type-${event.type || 'default'}`]"
:style="getContinuousEventStyle(event)"
3 months ago
:title="getEventTooltip(event, new Date(event.segStartISO))"
4 months ago
@click.stop="openCreateModal('editor', event.id)"
>
{{ event.title }}
</div>
</div>
</div>
</div>
</div>
<addCalendar ref="addCalendar" @refresh="getList"></addCalendar>
</div>
</template>
<script>
import addCalendar from './components/addCalendar.vue'
import {
index
} from '@/api/calendars/index.js'
export default {
components: {
addCalendar
},
data() {
return {
list: [],
calendarDate: new Date()
}
},
computed: {
selectMonth() {
const now = this.calendarDate instanceof Date ? this.calendarDate : new Date(this.calendarDate)
const month = now.getMonth() + 1 < 10 ? '0' + (now.getMonth() + 1) : now.getMonth() + 1
const year = now.getFullYear()
return year + '-' + month
}
},
watch: {
calendarDate: {
handler() {
this.getList()
},
deep: true
}
},
created() {
this.getList()
},
methods: {
async getList() {
const res = await index({
month: this.selectMonth
})
this.list = res
},
openCreateModal(type, id) {
if (type === 'editor') {
this.$refs.addCalendar.id = id
}
this.$refs.addCalendar.type = type
this.$refs.addCalendar.isShow = true
},
eventsForDate(date) {
const d = new Date(date)
return this.list.filter(ev => {
const startDate = this.parseDateTime(ev.start_time)
// 如果没有end_time只在start_time的日期显示
if (!ev.end_time) {
const currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
return currentDate.getTime() === eventStartDate.getTime()
}
const endDate = this.parseDateTime(ev.end_time)
// 判断是否是跨天事件
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
// 跨天事件不在单元格中显示,只在覆盖层显示
if (isMultiDay) {
return false
}
// 单天事件正常显示
const currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
return currentDate.getTime() === eventStartDate.getTime()
})
},
getEventClass(event, date) {
const startDate = new Date(event.start_time)
const currentDate = new Date(date)
// 如果没有end_time直接返回单天事件样式
if (!event.end_time) {
return `single-day event-type-${event.type || 'default'}`
}
const endDate = new Date(event.end_time)
// 判断是否是跨天事件
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (!isMultiDay) {
return `single-day event-type-${event.type || 'default'}`
}
// 判断当前日期在跨天事件中的位置
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const eventEndDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const currentDateOnly = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
if (currentDateOnly.getTime() === eventStartDate.getTime()) {
return `multi-day-start event-type-${event.type || 'default'}`
} else if (currentDateOnly.getTime() === eventEndDate.getTime()) {
return `multi-day-end event-type-${event.type || 'default'}`
} else {
return `multi-day-middle event-type-${event.type || 'default'}`
}
},
getEventTooltip(event, date) {
const startDate = new Date(event.start_time)
// 如果没有end_time只显示事件标题和开始时间
if (!event.end_time) {
return `${event.title}\n时间${this.formatDateTime(event.start_time)}`
}
const endDate = new Date(event.end_time)
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (isMultiDay) {
return `${event.title}\n时间${this.formatDateTime(event.start_time)} ~ ${this.formatDateTime(event.end_time)}`
} else {
return `${event.title}\n时间${this.formatDateTime(event.start_time)} ~ ${this.formatDateTime(event.end_time)}`
}
},
3 months ago
getEventInlineStyle(event) {
// 单天事件或在格子中显示的事件,若有自定义颜色则覆盖背景
if (event) {
const custom = event.color
if (custom) {
try { console.log('[Calendar] Single-day color applied:', event.id || event._id, custom) } catch (e) {}
return { background: custom }
}
// 无自定义颜色时,显式使用类型默认色,避免出现无背景的情况
const fallback = this.getEventTypeColor(event.type)
return { background: fallback }
}
return {}
},
4 months ago
getContinuousEvents() {
const FIRST_DOW = 0 // 0=Sunday to match :first-day-of-week="7" on el-calendar
const continuousEvents = []
// 仅选择跨天事件
const multiDayEvents = this.list.filter(ev => {
if (!ev.end_time) return false
const s = this.parseDateTime(ev.start_time)
const e = this.parseDateTime(ev.end_time)
return s.toDateString() !== e.toDateString()
})
const currentMonth = this.calendarDate.getMonth()
const currentYear = this.calendarDate.getFullYear()
const monthStart = new Date(currentYear, currentMonth, 1)
const monthEnd = new Date(currentYear, currentMonth + 1, 0)
function getWeekStart(date) {
const d = new Date(date)
const jsDow = d.getDay() // 0..6 (Sun..Sat)
const offset = (jsDow - FIRST_DOW + 7) % 7
d.setDate(d.getDate() - offset)
return d
}
multiDayEvents.forEach(ev => {
const eventStart = this.parseDateTime(ev.start_time)
const eventEnd = this.parseDateTime(ev.end_time)
if (eventEnd < monthStart || eventStart > monthEnd) return
// Clamp to month range so we only render in current month viewport
const clampedStart = eventStart < monthStart ? monthStart : eventStart
const clampedEnd = eventEnd > monthEnd ? monthEnd : eventEnd
let cursor = getWeekStart(clampedStart)
while (cursor <= clampedEnd) {
const weekStart = new Date(cursor)
const weekEnd = new Date(cursor)
weekEnd.setDate(weekEnd.getDate() + 6)
// Segment inside this week
const segStart = clampedStart > weekStart ? clampedStart : weekStart
const segEnd = clampedEnd < weekEnd ? clampedEnd : weekEnd
if (segStart <= segEnd) {
const startCol = (segStart.getDay() - FIRST_DOW + 7) % 7
const endCol = (segEnd.getDay() - FIRST_DOW + 7) % 7
const spanCols = endCol - startCol + 1
continuousEvents.push({
...ev,
weekStartISO: weekStart.toISOString(),
segStartISO: segStart.toISOString(),
segEndISO: segEnd.toISOString(),
startCol,
spanCols,
endCol
})
}
cursor.setDate(cursor.getDate() + 7)
}
})
// 为同一周的分段分配“轨道”lane避免垂直重叠
const byWeek = {}
continuousEvents.forEach(seg => {
const key = seg.weekStartISO
if (!byWeek[key]) byWeek[key] = []
byWeek[key].push(seg)
})
Object.values(byWeek).forEach(segs => {
// 按开始列排序,开始相同则优先更长的,减少冲突
segs.sort((a, b) => (a.startCol - b.startCol) || (b.spanCols - a.spanCols))
const laneEndCols = [] // 每个轨道当前占据的最后列
segs.forEach(seg => {
let placed = false
for (let i = 0; i < laneEndCols.length; i += 1) {
if (laneEndCols[i] < seg.startCol) {
seg.laneIndex = i
laneEndCols[i] = seg.endCol
placed = true
break
}
}
if (!placed) {
seg.laneIndex = laneEndCols.length
laneEndCols.push(seg.endCol)
}
})
})
return continuousEvents
},
getContinuousEventStyle(event) {
const FIRST_DOW = 0
const currentMonth = this.calendarDate.getMonth()
const currentYear = this.calendarDate.getFullYear()
const firstDay = new Date(currentYear, currentMonth, 1)
3 months ago
// 使用实际分段起点,不做额外周偏移
const segStart = new Date(event.segStartISO)
4 months ago
const msPerDay = 1000 * 60 * 60 * 24
3 months ago
const daysFromFirstOfMonth = Math.floor((segStart - firstDay) / msPerDay)
4 months ago
const firstDayOffset = (firstDay.getDay() - FIRST_DOW + 7) % 7
const totalDaysFromCalendarStart = daysFromFirstOfMonth + firstDayOffset
const weekRow = Math.floor(totalDaysFromCalendarStart / 7)
3 months ago
// 起始列使用预计算的列(相对于该周)
const startColAdjusted = event.startCol
4 months ago
const cellWidth = 100 / 7
const cellHeight = 100
3 months ago
const headerHeight = 0
const cellPadding = 6 // td 与内部容器的总上内边距(约值)
4 months ago
const dateNumberHeight = 25
const eventHeight = 16
const eventSpacing = 2
const verticalOffset = (event.laneIndex || 0) * (eventHeight + eventSpacing)
3 months ago
const custom = event.color || event.bg_color || event.background
if (custom) { try { console.log('[Calendar] Continuous color applied:', event.id, custom) } catch (e) {} }
4 months ago
return {
position: 'absolute',
left: `calc(${startColAdjusted * cellWidth}% + 2px)`,
3 months ago
top: `${headerHeight + weekRow * cellHeight + cellPadding + dateNumberHeight + verticalOffset}px`,
4 months ago
width: `calc(${event.spanCols * cellWidth}% - 4px)`,
height: `${eventHeight}px`,
zIndex: 1000,
3 months ago
background: custom ? custom : `linear-gradient(90deg, ${this.getEventTypeColor(event.type)} 0%, ${this.darkenColor(this.getEventTypeColor(event.type))} 100%)`,
backgroundColor: custom ? custom : undefined,
4 months ago
borderRadius: '3px',
fontSize: '11px',
lineHeight: `${eventHeight}px`,
color: 'white',
padding: '0 4px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: 'pointer',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.3)'
}
},
getEventStyle(event, date) {
const startDate = new Date(event.start_time)
const currentDate = new Date(date)
// 如果没有end_time使用默认样式
if (!event.end_time) {
return {}
}
const endDate = new Date(event.end_time)
// 判断是否是跨天事件
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (!isMultiDay) {
return {}
}
// 获取当前周的开始日期(周日)
const currentWeekStart = new Date(currentDate)
const dayOfWeek = currentDate.getDay()
currentWeekStart.setDate(currentDate.getDate() - dayOfWeek)
// 计算事件在当前周的开始和结束位置
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const eventEndDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const currentDateOnly = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
// 计算当前周内事件的开始和结束天数
const weekStart = new Date(currentWeekStart)
const weekEnd = new Date(currentWeekStart)
weekEnd.setDate(weekEnd.getDate() + 6)
// 事件在当前周的实际开始和结束日期
const eventWeekStart = eventStartDate < weekStart ? weekStart : eventStartDate
const eventWeekEnd = eventEndDate > weekEnd ? weekEnd : eventEndDate
// 如果当前日期不在事件范围内,不应用特殊样式
if (currentDateOnly < eventWeekStart || currentDateOnly > eventWeekEnd) {
return {}
}
// 如果是事件在当前周的第一天,显示标题并延伸到周末尾或事件结束
if (currentDateOnly.getTime() === eventWeekStart.getTime()) {
const startDayOfWeek = eventWeekStart.getDay()
const endDayOfWeek = eventWeekEnd.getDay()
const spanDays = endDayOfWeek - startDayOfWeek + 1
// 根据事件类型设置背景色
const bgColor = this.getEventTypeColor(event.type)
return {
position: 'absolute',
left: '0',
top: '1px',
width: `calc(${spanDays * 100}% - 2px)`,
zIndex: 10,
background: `linear-gradient(90deg, ${bgColor} 0%, ${this.darkenColor(bgColor)} 100%)`,
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '3px'
}
} else if (currentDateOnly > eventWeekStart && currentDateOnly <= eventWeekEnd) {
// 其他天完全隐藏内容,保留占位
return {
opacity: '0',
pointerEvents: 'none'
}
}
return {}
},
getEventDisplayTitle(event, date) {
const startDate = new Date(event.start_time)
const currentDate = new Date(date)
// 如果没有end_time显示完整标题
if (!event.end_time) {
return event.title
}
const endDate = new Date(event.end_time)
// 判断是否是跨天事件
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (!isMultiDay) {
return event.title
}
// 对于跨天事件,只在开始日期显示标题
const eventStartDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const currentDateOnly = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
// 获取当前周的开始日期
const currentWeekStart = new Date(currentDate)
const dayOfWeek = currentDate.getDay()
currentWeekStart.setDate(currentDate.getDate() - dayOfWeek)
const weekStart = new Date(currentWeekStart)
const eventWeekStart = eventStartDate < weekStart ? weekStart : eventStartDate
if (currentDateOnly.getTime() === eventWeekStart.getTime()) {
return event.title
}
return '' // 其他日期不显示标题
},
getEventTypeColor(type) {
// 根据事件类型返回不同颜色
const colorMap = {
1: '#67C23A', // 课程 - 绿色
2: '#409EFF', // 会议 - 蓝色
3: '#E6A23C', // 自定义事件 - 橙色
4: '#F56C6C', // 资讯 - 红色
5: '#909399', // 其他 - 灰色
default: '#409EFF' // 默认蓝色
}
return colorMap[type] || colorMap.default
},
// 可靠的日期解析:避免 Safari/时区导致的偏移
parseDateTime(dateTimeStr) {
if (!dateTimeStr) return null
// 支持 "YYYY-MM-DD HH:mm:ss" 或 "YYYY-MM-DD" 形式
const [datePart, timePart = '00:00:00'] = dateTimeStr.trim().split(/\s+/)
const [y, m, d] = datePart.split('-').map(n => parseInt(n, 10))
const [hh = 0, mm = 0, ss = 0] = timePart.split(':').map(n => parseInt(n, 10))
return new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0, ss || 0)
},
darkenColor(color) {
// 将颜色变暗,用于渐变效果
const colorMap = {
'#67C23A': '#5CB85C', // 绿色变暗
'#409EFF': '#337ecc', // 蓝色变暗
'#E6A23C': '#D4952B', // 橙色变暗
'#F56C6C': '#E85555', // 红色变暗
'#909399': '#73767A' // 灰色变暗
}
return colorMap[color] || '#337ecc'
},
formatDateTime(val) {
if (!val) return ''
return val.replace('T', ' ')
},
typeText(className) {
if (!className) return ''
if (className === 1) return '课程'
if (className === 3) return '自定义事件'
if (className === 4) return '资讯'
return ''
}
},
filters: {
formatDateTime(val) {
if (!val) return ''
return val.replace('T', ' ')
}
}
}
</script>
<style scoped lang="scss">
::v-deep .el-calendar__body{
padding-left:0;
padding-right:0;
}
.admin-calendar {
background: #f4f6fa;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-header {
background: transparent;
3 months ago
padding: 20rpx 30rpx 10rpx 30rpx;
4 months ago
border-bottom: none;
box-shadow: none;
}
.admin-main {
flex: 1;
3 months ago
padding: 20rpx 30rpx;
4 months ago
overflow: hidden;
}
.calendar-panel {
background: #fff;
border-radius: 12px;
3 months ago
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
padding: 24rpx;
4 months ago
height: 100%;
overflow: auto;
}
.calendar-wrapper {
position: relative;
}
.continuous-events-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 100;
}
.continuous-event {
pointer-events: auto;
transition: all 0.2s ease;
}
.continuous-event:hover {
3 months ago
transform: translateY(-1rpx);
4 months ago
filter: brightness(1.1);
z-index: 101;
}
/* Element UI 日历样式覆盖 */
.calendar-panel ::v-deep .el-calendar-table {
overflow: visible;
}
.calendar-panel ::v-deep .el-calendar-table .el-calendar-day {
position: relative;
overflow: visible;
height: auto;
3 months ago
min-height: 100rpx;
padding: 4rpx;
4 months ago
}
.calendar-panel ::v-deep .el-calendar-table td {
position: relative;
overflow: visible;
3 months ago
border: 1rpx solid #ebeef5;
4 months ago
}
.calendar-panel ::v-deep .el-calendar-table tbody tr {
overflow: visible;
}
.calendar-panel ::v-deep .el-calendar-table tbody {
overflow: visible;
}
.cell-content {
position: relative;
3 months ago
min-height: 100rpx;
4 months ago
display: flex;
flex-direction: column;
3 months ago
padding: 2rpx;
4 months ago
overflow: visible;
}
.date-number {
font-weight: bold;
color: #333;
3 months ago
margin-bottom: 2rpx;
4 months ago
position: relative;
z-index: 1;
}
.event-list {
flex: 1;
overflow: visible;
position: relative;
z-index: 5;
}
.event-item {
3 months ago
font-size: 11rpx;
line-height: 14rpx;
padding: 1rpx 3rpx;
margin: 1rpx 0;
4 months ago
background: #409EFF;
color: white;
border-radius: 3px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: background-color 0.2s;
position: relative;
}
.event-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-item:hover {
background: #337ecc;
}
/* 不同事件类型的颜色 */
.event-item.event-type-1 {
background: #67C23A; /* 课程 - 绿色 */
}
.event-item.event-type-2 {
background: #409EFF; /* 会议 - 蓝色 */
}
.event-item.event-type-3 {
background: #E6A23C; /* 自定义事件 - 橙色 */
}
.event-item.event-type-4 {
background: #F56C6C; /* 资讯 - 红色 */
}
.event-item.event-type-5 {
background: #909399; /* 其他 - 灰色 */
}
.event-item.event-type-default {
background: #409EFF; /* 默认 - 蓝色 */
}
/* 悬停效果 */
.event-item.event-type-1:hover {
background: #5CB85C;
}
.event-item.event-type-2:hover {
background: #337ecc;
}
.event-item.event-type-3:hover {
background: #D4952B;
}
.event-item.event-type-4:hover {
background: #E85555;
}
.event-item.event-type-5:hover {
background: #73767A;
}
.event-item.event-type-default:hover {
background: #337ecc;
}
/* 连续事件的特殊样式 */
.event-item[style*="position: absolute"] {
border-radius: 3px !important;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-item[style*="position: absolute"]:hover {
3 months ago
transform: translateY(-1rpx);
4 months ago
filter: brightness(1.1);
}
.mt-4 {
3 months ago
margin-top: 24rpx;
4 months ago
}
.mb-2 {
3 months ago
margin-bottom: 8rpx;
4 months ago
}
@media (max-width: 1200px) {
.admin-header {
3 months ago
padding: 15rpx 20rpx;
4 months ago
}
.admin-main {
3 months ago
padding: 15rpx 20rpx;
4 months ago
}
.calendar-panel {
3 months ago
padding: 16rpx;
4 months ago
}
}
</style>