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.

1122 lines
39 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" style="display: flex; align-items: center; gap: 20px;">
<el-button type="success" icon="el-icon-plus" @click="openCreateModal('add')"></el-button>
<el-button type="primary" icon="el-icon-plus" @click="exportCalendar"></el-button>
<div style="color: #333; font-size: 16px; font-weight: 500;">
本月开课天数{{ monthDayCalendar }} {{ calendarDate.getFullYear() }}年累计开课天数{{ yearDayCalendar }}
</div>
</div>
<!-- 日历预览区 -->
<div class="admin-main">
<div class="calendar-panel">
<div class="calendar-wrapper">
<el-calendar v-model="calendarDate" :first-day-of-week="1">
<template slot="dateCell" slot-scope="{date}">
<div class="cell-content" @click.stop.prevent="onDateCellClick(date)">
<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="getEventItemStyle(ev)"
:title="getEventTooltip(ev, date)"
@click.stop="openCreateModal('editor', ev.id)"
>
<span class="event-title">{{ ev.title }}</span>
</div>
</div>
</div>
</template>
</el-calendar>
<!-- 跨天事件条:直接作为 wrapper 的绝对定位子元素渲染,避免覆盖层拦截点击 -->
<div
v-for="event in getContinuousEvents()"
:key="`continuous-${event.id}-${event.segStartISO}`"
:class="['continuous-event', getContinuousEventClass(event)]"
:style="getContinuousEventStyle(event)"
:title="getEventTooltip(event, new Date(event.segStartISO))"
@click.stop="openCreateModal('editor', event.id)"
>
{{ event.title }}
</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'
import { getToken } from '@/utils/auth';
export default {
components: {
addCalendar
},
data() {
return {
list: [],
calendarDate: new Date(),
// 记录每一周行在容器内的实际像素 top解决不等高行导致定位偏差
weekRowTops: [],
monthDayCalendar: 0,
yearDayCalendar: 0
}
},
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(newVal, oldVal) {
if (!oldVal) return
const n = newVal instanceof Date ? newVal : new Date(newVal)
const o = oldVal instanceof Date ? oldVal : new Date(oldVal)
if (n.getFullYear() !== o.getFullYear() || n.getMonth() !== o.getMonth()) {
this.getList()
}
}
}
},
created() {
this.getList()
this.generateDynamicStyles()
},
mounted() {
this.$nextTick(() => this.measureWeekRowTops())
window.addEventListener('resize', this.measureWeekRowTops)
},
beforeDestroy() {
window.removeEventListener('resize', this.measureWeekRowTops)
},
methods: {
async exportCalendar() {
try {
await this.$confirm('请选择导出范围', '导出日历事件', {
confirmButtonText: '导出本月',
cancelButtonText: '导出全部',
distinguishCancelAndClose: true,
type: 'info'
})
this.triggerExport(this.selectMonth)
} catch (action) {
if (action === 'cancel') {
this.triggerExport('')
}
// 关闭弹框不做任何操作
}
},
triggerExport(month) {
const monthQuery = month ? `month=${month}` : 'month='
const url =
process.env.VUE_APP_BASE_API +
`/api/admin/calendars/index?${monthQuery}` +
'&is_export=1&export_fields[is_publish_text]=是否对外展示' +
'&export_fields[is_count_days_text]=是否统计天数' +
'&export_fields[is_count_people_text]=是否统计人数' +
'&export_fields[days]=天数' +
'&export_fields[type_text]=日程类型' +
'&export_fields[course.name]=课程名称' +
'&export_fields[introduce]=具体说明' +
'&export_fields[title]=标题' +
'&export_fields[url]=资讯链接' +
'&export_fields[start_time]=开始时间' +
'&export_fields[end_time]=截止时间' +
'&export_fields[address]=地址' +
'&export_fields[color]=主题颜色' +
'&export_fields[history_courses]=课程人数' +
'&export_fields[content]=内容' +
'&token=' + getToken()
window.open(url, '_blank')
},
onDateCellClick() {
// 阻止 el-calendar 默认点击日期触发的月份切换
return false
},
async getList() {
const res = await index({
month: this.selectMonth
})
// 统一规范化 id避免 _id 与 id 不一致导致点击与定位异常
this.list = (res.list || res || []).map(e => ({ ...e, id: e.id || e._id }))
this.monthDayCalendar = res.monthDayCalendar || 0
this.yearDayCalendar = res.yearDayCalendar || 0
// 重新生成动态样式
this.generateDynamicStyles()
// 渲染后测量行位置信息
this.$nextTick(() => this.measureWeekRowTops())
},
// 计算某一天内单天事件的轨道数量,用于为跨天条预留垂直空间
getSingleDayLaneCount(date) {
const d = new Date(date)
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const dayEvents = (this.list || []).filter(ev => {
if (!ev) return false
const s = this.parseDateTime(ev.start_time)
if (!s) return false
const sOnly = new Date(s.getFullYear(), s.getMonth(), s.getDate())
const hasEnd = !!ev.end_time
const e = hasEnd ? this.parseDateTime(ev.end_time) : s
const eOnly = new Date(e.getFullYear(), e.getMonth(), e.getDate())
// 仅统计单天事件
const isMulti = hasEnd && (sOnly.getTime() !== eOnly.getTime())
if (isMulti) return false
return sOnly.getTime() === target.getTime()
})
if (dayEvents.length === 0) return 0
// 简化的轨道计算(与 arrangeEventsVertically 同逻辑)
const sorted = dayEvents.sort((a, b) => {
const as = this.parseDateTime(a.start_time)
const bs = this.parseDateTime(b.start_time)
return (as ? as.getTime() : 0) - (bs ? bs.getTime() : 0)
})
const lanes = []
sorted.forEach(ev => {
const s = this.parseDateTime(ev.start_time)
const e = ev.end_time ? this.parseDateTime(ev.end_time) : s
const sh = s.getHours() + s.getMinutes() / 60
const eh = e.getHours() + e.getMinutes() / 60
let placed = false
for (let i = 0; i < lanes.length; i += 1) {
const lane = lanes[i]
const conflict = lane.some(r => !(eh <= r.start || sh >= r.end))
if (!conflict) {
lane.push({ start: sh, end: eh })
placed = true
break
}
}
if (!placed) {
lanes.push([{ start: sh, end: eh }])
}
})
return lanes.length
},
// 计算跨天切片跨度内最大单天轨道数
getMaxSingleDayLaneCountBetween(startISO, endISO) {
if (!startISO || !endISO) return 0
const start = new Date(startISO)
const end = new Date(endISO)
const cursor = new Date(start)
let maxCount = 0
while (cursor <= end) {
maxCount = Math.max(maxCount, this.getSingleDayLaneCount(cursor))
cursor.setDate(cursor.getDate() + 1)
}
return maxCount
},
// 测量日历每一行(周)的实际 top用于跨天条精确定位
measureWeekRowTops() {
try {
const wrapper = this.$el.querySelector('.calendar-wrapper')
const wrapperTop = wrapper ? wrapper.getBoundingClientRect().top : 0
const tableRows = this.$el.querySelectorAll('.el-calendar-table tbody tr')
if (!tableRows || !tableRows.length) return
const tops = []
tableRows.forEach(row => {
const rect = row.getBoundingClientRect()
const relTop = rect.top - wrapperTop
tops.push(relTop)
})
this.weekRowTops = tops
} catch (e) {
// 忽略测量异常
}
},
openCreateModal(type, id) {
// 调试日志:记录点击行为与入参
try { console.log('[calendar] openCreateModal called with:', { type, id }) } catch (e) {}
// 兼容传入对象或 id/_id 的情况,确保能正确打开编辑弹窗
const finalId = (id && typeof id === 'object') ? (id.id || id._id) : (id || null)
try { console.log('[calendar] resolved finalId:', finalId) } catch (e) {}
const addRef = this.$refs && this.$refs.addCalendar
if (!addRef) {
try { console.warn('[calendar] addCalendar ref not found') } catch (e) {}
return
}
if (type === 'editor') {
addRef.id = finalId
}
addRef.type = type
addRef.isShow = true
try { console.log('[calendar] modal state set:', { id: addRef.id, type: addRef.type, isShow: addRef.isShow }) } catch (e) {}
},
eventsForDate(date) {
const d = new Date(date)
const currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const oneDayEvents = [];
(this.list || []).forEach(ev => {
const startDate = this.parseDateTime(ev.start_time)
const startOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
const hasEnd = !!ev.end_time
const endDate = hasEnd ? this.parseDateTime(ev.end_time) : startDate
const endOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
// 仅单天事件在格子内渲染;跨天交给覆盖层
const isMultiDay = hasEnd && (startOnly.getTime() !== endOnly.getTime())
if (isMultiDay) return
if (currentDate.getTime() === startOnly.getTime()) {
oneDayEvents.push({
...ev,
id: ev.id || ev._id
})
}
})
// 为同一天所有事件分配垂直位置,避免重叠
return this.arrangeEventsVertically(oneDayEvents)
},
// 为同一天的事件分配垂直位置,避免重叠
arrangeEventsVertically(events) {
if (!events || events.length === 0) return events
// 按开始时间排序(片段无具体时间则按标题/ID兜底
const sortedEvents = events.sort((a, b) => {
const aStart = this.parseDateTime(a.start_time)
const bStart = this.parseDateTime(b.start_time)
const aTs = aStart ? aStart.getTime() : 0
const bTs = bStart ? bStart.getTime() : 0
const diff = aTs - bTs
if (diff !== 0) return diff
return String(a.id).localeCompare(String(b.id))
})
// 为每个事件分配垂直位置
const lanes = [] // 存储每个时间段的占用情况
const eventHeight = 16 // 事件高度
const eventSpacing = 2 // 事件间距
sortedEvents.forEach(event => {
const startTime = this.parseDateTime(event.start_time)
const endTime = event.end_time ? this.parseDateTime(event.end_time) : startTime
// 片段(跨天按全天)采用 0..24,单天按具体时间
const isSegment = !!event.isSegment
const startHour = isSegment && event.isMultiDay ? 0 : (startTime.getHours() + startTime.getMinutes() / 60)
const endHour = isSegment && event.isMultiDay ? 24 : (endTime.getHours() + endTime.getMinutes() / 60)
// 找到可用的垂直位置
let laneIndex = 0
let foundLane = false
for (let i = 0; i < lanes.length; i++) {
const lane = lanes[i]
// 检查当前时间段是否与已有事件冲突
const hasConflict = lane.some(occupied => {
return !(endHour <= occupied.start || startHour >= occupied.end)
})
if (!hasConflict) {
laneIndex = i
foundLane = true
break
}
}
// 如果没有找到可用位置,创建新的位置
if (!foundLane) {
laneIndex = lanes.length
lanes.push([])
}
// 记录当前事件占用的时间段
lanes[laneIndex].push({
start: startHour,
end: endHour
})
// 为事件添加垂直位置信息
event.verticalPosition = laneIndex
// 自上而下堆叠:从顶部开始计算偏移
event.topOffset = laneIndex * (eventHeight + eventSpacing)
})
return sortedEvents
},
// 获取同一天内所有事件(包括跨天事件)的垂直位置分配
getDayEventsWithPositions(date) {
const d = new Date(date)
const currentDateStr = d.toDateString()
// 获取单天事件
const singleDayEvents = this.list.filter(ev => {
const startDate = this.parseDateTime(ev.start_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 multiDayEvents = this.getContinuousEvents().filter(ev => {
const eventStart = new Date(ev.segStartISO)
const eventEnd = new Date(ev.segEndISO)
return eventStart <= d && d <= eventEnd
})
// 合并所有事件
const allEvents = [
...singleDayEvents.map(ev => ({ ...ev, isMultiDay: false })),
...multiDayEvents.map(ev => ({ ...ev, isMultiDay: true }))
]
// 为所有事件分配垂直位置
return this.arrangeEventsVertically(allEvents)
},
// 获取事件项的样式
getEventItemStyle(event) {
if (event.topOffset !== undefined) {
return {
position: 'relative',
top: `${Math.max(0, event.topOffset - 8)}px`,
zIndex: 70 + (event.verticalPosition || 0) // 单天事件保持在顶层
}
}
return {}
},
getEventClass(event, date) {
const startDate = new Date(event.start_time)
const currentDate = new Date(date)
// 基础样式类
let baseClass = ''
// 如果没有end_time直接返回单天事件样式
if (!event.end_time) {
baseClass = 'single-day'
} else {
const endDate = new Date(event.end_time)
// 判断是否是跨天事件
const isMultiDay = startDate.getDate() !== endDate.getDate() ||
startDate.getMonth() !== endDate.getMonth() ||
startDate.getFullYear() !== endDate.getFullYear()
if (!isMultiDay) {
baseClass = 'single-day'
} else {
// 判断当前日期在跨天事件中的位置
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()) {
baseClass = 'multi-day-start'
} else if (currentDateOnly.getTime() === eventEndDate.getTime()) {
baseClass = 'multi-day-end'
} else {
baseClass = 'multi-day-middle'
}
}
}
// 如果有自定义颜色添加color类否则使用默认的type类
if (event.color) {
return `${baseClass} event-color-${event.color.replace('#', '')}`
} else {
return `${baseClass} 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)}`
}
},
getContinuousEvents() {
const FIRST_DOW = 1 // 1=Monday to match :first-day-of-week="1"
const OFFSET_DAYS = 0 // 不做任何全局偏移
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 singleDayEvents = this.list.filter(ev => {
if (!ev.end_time) return true
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)
// 规范化到 00:00:00避免不同时间导致同一周被分到不同key
d.setHours(0, 0, 0, 0)
return d
}
function adjustEndForDisplay(d) {
// 若结束时间恰好为 00:00:00则视为不包含该天显示到前一日避免与下一事件在同日重叠
const end = new Date(d)
if (end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0) {
end.setSeconds(end.getSeconds() - 1)
}
return end
}
multiDayEvents.forEach(ev => {
const eventStart = this.parseDateTime(ev.start_time)
const eventEndRaw = this.parseDateTime(ev.end_time)
const eventEnd = adjustEndForDisplay(eventEndRaw)
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
// 检查跨天事件是否与单天事件冲突
const hasSingleDayConflict = (date) => {
const dateStr = date.toDateString()
return singleDayEvents.some(singleEv => {
const singleStart = this.parseDateTime(singleEv.start_time)
return singleStart.toDateString() === dateStr
})
}
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
// 计算用于显示的偏移后起点与周键
const adjStart = new Date(segStart)
adjStart.setDate(adjStart.getDate() + OFFSET_DAYS)
const displayWeekStart = getWeekStart(adjStart)
const displayStartCol = (adjStart.getDay() - FIRST_DOW + 7) % 7
const displayEndCol = displayStartCol + spanCols - 1
continuousEvents.push({
...ev,
weekStartISO: weekStart.toISOString(),
segStartISO: segStart.toISOString(),
segEndISO: segEnd.toISOString(),
startCol,
spanCols,
endCol,
// 显示用字段(含偏移)
adjSegStartISO: adjStart.toISOString(),
displayWeekStartISO: displayWeekStart.toISOString(),
displayStartCol,
displayEndCol,
laneIndex: 0,
// 添加冲突信息,用于后续处理
hasSingleDayConflict: hasSingleDayConflict
})
}
cursor.setDate(cursor.getDate() + 7)
}
})
// 为同一周的分段分配“轨道”lane避免垂直重叠
const byWeek = {}
continuousEvents.forEach(seg => {
const key = seg.displayWeekStartISO || seg.weekStartISO
if (!byWeek[key]) byWeek[key] = []
byWeek[key].push(seg)
})
Object.values(byWeek).forEach(segs => {
// 排序:开始列优先,其次跨度更长,最后按 id 稳定
segs.sort((a, b) => (a.displayStartCol - b.displayStartCol) || (b.spanCols - a.spanCols) || String(a.id).localeCompare(String(b.id)))
// 使用严格区间冲突检测的多区间轨道
const lanes = [] // 每个元素是该轨道内的区间数组 [{start,end}]
segs.forEach(seg => {
let laneIndex = 0
let placed = false
for (let i = 0; i < lanes.length; i += 1) {
const intervals = lanes[i]
const overlaps = intervals.some(it => !(seg.displayEndCol < it.start || seg.displayStartCol > it.end))
if (!overlaps) {
intervals.push({ start: seg.displayStartCol, end: seg.displayEndCol })
laneIndex = i
placed = true
break
}
}
if (!placed) {
laneIndex = lanes.length
lanes.push([{ start: seg.displayStartCol, end: seg.displayEndCol }])
}
seg.laneIndex = laneIndex
})
})
return continuousEvents
},
getContinuousEventClass(event) {
const baseClass = 'continuous-event'
if (event.color) {
return `${baseClass} event-color-${event.color.replace('#', '')}`
} else {
return `${baseClass} event-type-${event.type || 'default'}`
}
},
getContinuousEventStyle(event) {
const FIRST_DOW = 1
const currentMonth = this.calendarDate.getMonth()
const currentYear = this.calendarDate.getFullYear()
const firstDay = new Date(currentYear, currentMonth, 1)
// 可控的视觉偏移:使用预计算的偏移起点
const adjStart = new Date(event.adjSegStartISO || event.segStartISO)
const msPerDay = 1000 * 60 * 60 * 24
const firstDayOffset = (firstDay.getDay() - FIRST_DOW + 7) % 7
// 使用显示用周起点来确定行,确保与分组一致
const displayWeekStart = new Date(event.displayWeekStartISO || adjStart)
const daysFromFirstOfMonth = Math.floor((displayWeekStart - firstDay) / msPerDay)
const totalDaysFromCalendarStart = daysFromFirstOfMonth + firstDayOffset
const weekRow = Math.floor(totalDaysFromCalendarStart / 7)
// 起始列使用偏移后的日期重新计算(若已携带显示列则直接用)
const startColAdjusted = (event.displayStartCol != null)
? event.displayStartCol
: (adjStart.getDay() - FIRST_DOW + 7) % 7
const cellWidth = 100 / 7
const cellHeight = 120 // 兜底高度
const overlayBaseTop = 50 // 与 CSS .continuous-events-overlay 保持一致
const dateNumberHeight = 8 // 进一步压缩日期占位
const eventHeight = 20
const eventSpacing = 3
// 使用跨天分段在本周内的 laneIndex 做垂直堆叠,避免同一周同一行的跨天事件重叠
const verticalOffset = (event.laneIndex || 0) * (eventHeight + eventSpacing)
// 基于实际测量的行 top没有测到则退回计算值
const measuredRowTop = (this.weekRowTops && this.weekRowTops[weekRow] != null)
? this.weekRowTops[weekRow]
: (weekRow * cellHeight)
// 为跨天条在该分段跨度内预留单天事件堆叠高度,避免遮挡
const singleDayLanes = this.getMaxSingleDayLaneCountBetween(event.segStartISO, event.segEndISO)
const singleDayStackPx = singleDayLanes * (16 + 2) // 与单天事件高度与间距保持一致
const safeGap = 0 // 去除额外间距,使横条进一步上移
return {
position: 'absolute',
left: `calc(${startColAdjusted * cellWidth}% + 1px)`,
top: `${overlayBaseTop + measuredRowTop + dateNumberHeight + safeGap + singleDayStackPx + verticalOffset}px`, // 预留单天事件空间+安全间距
width: `calc(${event.spanCols * cellWidth}% - 2px)`,
height: `${eventHeight}px`,
zIndex: 500, // 置于表格之上,结合预留空隙不遮挡单天,确保可点
background: `linear-gradient(90deg, ${this.getEventTypeColor(event.type)} 0%, ${this.darkenColor(this.getEventTypeColor(event.type))} 100%)`,
borderRadius: '3px',
fontSize: '12px',
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: '105px', // 使用固定值headerHeight(50) + dateNumberHeight(40) + 15px = 105px确保完全在日期数字下方
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 ''
},
// 生成动态样式
generateDynamicStyles() {
// 移除已存在的动态样式
const existingStyle = document.getElementById('dynamic-calendar-styles')
if (existingStyle) {
existingStyle.remove()
}
// 创建新的样式元素
const style = document.createElement('style')
style.id = 'dynamic-calendar-styles'
// 生成CSS规则
let cssRules = ''
// 为每个事件生成颜色样式
this.list.forEach(event => {
if (event.color) {
const colorClass = event.color.replace('#', '')
cssRules += `
.admin-calendar .event-item.event-color-${colorClass},
.admin-calendar .continuous-event.event-color-${colorClass} {
background: ${event.color} !important;
}
.admin-calendar .event-item.event-color-${colorClass}:hover,
.admin-calendar .continuous-event.event-color-${colorClass}:hover {
background: ${this.adjustBrightness(event.color, -20)} !important;
}
`
}
})
style.textContent = cssRules
document.head.appendChild(style)
},
// 调整颜色亮度
adjustBrightness(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16)
const amt = Math.round(2.55 * percent)
const R = (num >> 16) + amt
const G = (num >> 8 & 0x00FF) + amt
const B = (num & 0x0000FF) + amt
return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 +
(G < 255 ? G < 1 ? 0 : G : 255) * 0x100 +
(B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1)
}
},
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: 20px 30px 10px 30px;
border-bottom: none;
box-shadow: none;
}
.admin-main {
flex: 1;
padding: 20px 30px;
overflow: hidden;
}
.calendar-panel {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
padding: 24px;
height: 100%;
overflow: auto;
}
.calendar-wrapper {
position: relative;
}
/* wrapper */
.continuous-event {
pointer-events: auto; /* none */
transition: all 0.2s ease;
}
.continuous-event:hover {
transform: translateY(-1px);
filter: brightness(1.1);
z-index: 501; /* */
}
/* 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: 100px;
padding: 4px;
}
/* / */
.calendar-panel ::v-deep .el-calendar-table td.is-prev-month .el-calendar-day,
.calendar-panel ::v-deep .el-calendar-table td.is-next-month .el-calendar-day {
pointer-events: none;
}
.calendar-panel ::v-deep .el-calendar-table td {
position: relative;
overflow: visible;
border: 1px 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: 100px;
display: flex;
flex-direction: column;
padding: 2px;
overflow: visible;
}
.date-number {
font-weight: bold;
color: #ccc;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
width: 100%;
font-size: 24px;
text-align: center;
z-index: 1; /* */
pointer-events: none;
}
.event-list {
flex: 1;
overflow: visible;
position: relative;
z-index: 60; /* */
min-height: 60px; /* */
}
.event-item {
font-size: 11px;
line-height: 14px;
padding: 1px 3px;
margin: 1px 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;
height: 16px; /* */
display: flex;
align-items: center;
}
.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; /* - */
}
/* - color */
.event-item[class*="event-color-"] {
/* */
}
/* */
.continuous-event[class*="event-color-"] {
/* */
}
/* */
.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(-1px);
filter: brightness(1.1);
}
.mt-4 {
margin-top: 24px;
}
.mb-2 {
margin-bottom: 8px;
}
@media (max-width: 1200px) {
.admin-header {
padding: 15px 20px;
}
.admin-main {
padding: 15px 20px;
}
.calendar-panel {
padding: 16px;
}
}
</style>