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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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)]"
:style="getEventInlineStyle(ev)"
: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()"
:key="`continuous-${event.id}-${event.weekStartISO}-${event.startCol}`"
:class="['continuous-event', `event-type-${event.type || 'default'}`]"
:style="getContinuousEventStyle(event)"
:title="getEventTooltip(event, new Date(event.segStartISO))"
@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)}`
}
},
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 {}
},
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)
// 使用实际分段起点,不做额外周偏移
const segStart = new Date(event.segStartISO)
const msPerDay = 1000 * 60 * 60 * 24
const daysFromFirstOfMonth = Math.floor((segStart - firstDay) / msPerDay)
const firstDayOffset = (firstDay.getDay() - FIRST_DOW + 7) % 7
const totalDaysFromCalendarStart = daysFromFirstOfMonth + firstDayOffset
const weekRow = Math.floor(totalDaysFromCalendarStart / 7)
// 起始列使用预计算的列(相对于该周)
const startColAdjusted = event.startCol
const cellWidth = 100 / 7
const cellHeight = 100
const headerHeight = 0
const cellPadding = 6 // td 与内部容器的总上内边距(约值)
const dateNumberHeight = 25
const eventHeight = 16
const eventSpacing = 2
const verticalOffset = (event.laneIndex || 0) * (eventHeight + eventSpacing)
const custom = event.color || event.bg_color || event.background
if (custom) { try { console.log('[Calendar] Continuous color applied:', event.id, custom) } catch (e) {} }
return {
position: 'absolute',
left: `calc(${startColAdjusted * cellWidth}% + 2px)`,
top: `${headerHeight + weekRow * cellHeight + cellPadding + dateNumberHeight + verticalOffset}px`,
width: `calc(${event.spanCols * cellWidth}% - 4px)`,
height: `${eventHeight}px`,
zIndex: 1000,
background: custom ? custom : `linear-gradient(90deg, ${this.getEventTypeColor(event.type)} 0%, ${this.darkenColor(this.getEventTypeColor(event.type))} 100%)`,
backgroundColor: custom ? custom : undefined,
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;
padding: 20rpx 30rpx 10rpx 30rpx;
border-bottom: none;
box-shadow: none;
}
.admin-main {
flex: 1;
padding: 20rpx 30rpx;
overflow: hidden;
}
.calendar-panel {
background: #fff;
border-radius: 12px;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
padding: 24rpx;
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 {
transform: translateY(-1rpx);
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;
min-height: 100rpx;
padding: 4rpx;
}
.calendar-panel ::v-deep .el-calendar-table td {
position: relative;
overflow: visible;
border: 1rpx solid #ebeef5;
}
.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;
min-height: 100rpx;
display: flex;
flex-direction: column;
padding: 2rpx;
overflow: visible;
}
.date-number {
font-weight: bold;
color: #333;
margin-bottom: 2rpx;
position: relative;
z-index: 1;
}
.event-list {
flex: 1;
overflow: visible;
position: relative;
z-index: 5;
}
.event-item {
font-size: 11rpx;
line-height: 14rpx;
padding: 1rpx 3rpx;
margin: 1rpx 0;
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 {
transform: translateY(-1rpx);
filter: brightness(1.1);
}
.mt-4 {
margin-top: 24rpx;
}
.mb-2 {
margin-bottom: 8rpx;
}
@media (max-width: 1200px) {
.admin-header {
padding: 15rpx 20rpx;
}
.admin-main {
padding: 15rpx 20rpx;
}
.calendar-panel {
padding: 16rpx;
}
}
</style>