master
lion 1 month ago
parent cde5c4aeb6
commit a63dff0d38

@ -13,29 +13,18 @@
<div class="admin-main">
<div class="calendar-panel">
<div class="calendar-wrapper">
<el-calendar v-model="calendarDate" :first-day-of-week="1">
<el-calendar ref="mainCalendar" 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 class="event-list"></div>
</div>
</template>
</el-calendar>
<!-- 跨天事件条直接作为 wrapper 的绝对定位子元素渲染避免覆盖层拦截点击 -->
<!-- 统一事件条单天/跨天都在同一层按轨道排布 -->
<div
v-for="event in getContinuousEvents()"
:key="`continuous-${event.id}-${event.segStartISO}`"
v-for="event in getCalendarSegments()"
:key="`segment-${event.id}-${event.segStartISO}`"
:class="['continuous-event', getContinuousEventClass(event)]"
:style="getContinuousEventStyle(event)"
:title="getEventTooltip(event, new Date(event.segStartISO))"
@ -47,6 +36,50 @@
</div>
</div>
<addCalendar ref="addCalendar" @refresh="getList"></addCalendar>
<el-dialog
title="本月事件"
:visible.sync="monthEventsDialogVisible"
width="760px"
append-to-body
>
<div class="month-events-summary">
本月开课场次{{ monthCourseCount }}
本月开课天数{{ monthDayCalendarDisplay }}
</div>
<el-table
:data="monthEvents"
stripe
border
max-height="460"
@row-click="handleMonthEventRowClick"
>
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="start_time" label="开始时间" min-width="180" />
<el-table-column prop="end_time" label="截止时间" min-width="180">
<template slot-scope="scope">
{{ scope.row.end_time || '-' }}
</template>
</el-table-column>
<el-table-column prop="is_count_days_text" label="是否显示天数" min-width="120">
<template slot-scope="scope">
{{ scope.row.is_count_days_text || '-' }}
</template>
</el-table-column>
<el-table-column prop="is_count_people_text" label="是否统计人数" min-width="120">
<template slot-scope="scope">
{{ scope.row.is_count_people_text || '-' }}
</template>
</el-table-column>
<el-table-column prop="days" label="天数" min-width="80">
<template slot-scope="scope">
{{ scope.row.days || '-' }}
</template>
</el-table-column>
</el-table>
<span slot="footer" class="dialog-footer">
<el-button @click="monthEventsDialogVisible = false">关闭</el-button>
</span>
</el-dialog>
</div>
</template>
@ -70,7 +103,8 @@ import { getToken } from '@/utils/auth';
monthDayCalendar: 0,
yearDayCalendar: 0,
monthCourseCount: 0,
yearCourseCount: 0
yearCourseCount: 0,
monthEventsDialogVisible: false
}
},
computed: {
@ -79,6 +113,21 @@ import { getToken } from '@/utils/auth';
const month = now.getMonth() + 1 < 10 ? '0' + (now.getMonth() + 1) : now.getMonth() + 1
const year = now.getFullYear()
return year + '-' + month
},
monthEvents() {
return (this.list || [])
.slice()
.sort((a, b) => {
const aStart = this.parseDateTime(a.start_time)
const bStart = this.parseDateTime(b.start_time)
const diff = (aStart ? aStart.getTime() : 0) - (bStart ? bStart.getTime() : 0)
if (diff !== 0) return diff
return String(a.id || '').localeCompare(String(b.id || ''))
})
},
monthDayCalendarDisplay() {
const val = Number(this.monthDayCalendar || 0)
return Number.isFinite(val) ? val.toFixed(1) : '0.0'
}
},
watch: {
@ -99,10 +148,12 @@ import { getToken } from '@/utils/auth';
},
mounted() {
this.$nextTick(() => this.measureWeekRowTops())
this.$nextTick(() => this.ensureMonthEventsButton())
window.addEventListener('resize', this.measureWeekRowTops)
},
beforeDestroy() {
window.removeEventListener('resize', this.measureWeekRowTops)
this.removeMonthEventsButton()
},
methods: {
async exportCalendar() {
@ -148,6 +199,44 @@ import { getToken } from '@/utils/auth';
// el-calendar
return false
},
ensureMonthEventsButton() {
this.$nextTick(() => {
const root = this.$el
if (!root) return
const buttonGroup = root.querySelector('.el-calendar__button-group')
if (!buttonGroup) return
if (buttonGroup.querySelector('.month-events-btn')) return
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'el-button el-button--primary el-button--mini month-events-btn'
btn.innerHTML = '<span>本月事件</span>'
btn.addEventListener('click', this.openMonthEventsDialog)
buttonGroup.appendChild(btn)
})
},
removeMonthEventsButton() {
const root = this.$el
if (!root) return
const btn = root.querySelector('.month-events-btn')
if (!btn) return
btn.removeEventListener('click', this.openMonthEventsDialog)
btn.remove()
},
selectCalendarDate(type) {
const calendarRef = this.$refs && this.$refs.mainCalendar
if (!calendarRef || typeof calendarRef.selectDate !== 'function') return
calendarRef.selectDate(type)
this.$nextTick(() => this.measureWeekRowTops())
},
openMonthEventsDialog() {
this.monthEventsDialogVisible = true
},
handleMonthEventRowClick(row) {
if (!row) return
this.monthEventsDialogVisible = false
this.openCreateModal('editor', row.id)
},
async getList() {
const res = await index({
month: this.selectMonth
@ -161,7 +250,10 @@ import { getToken } from '@/utils/auth';
//
this.generateDynamicStyles()
//
this.$nextTick(() => this.measureWeekRowTops())
this.$nextTick(() => {
this.measureWeekRowTops()
this.ensureMonthEventsButton()
})
},
//
getSingleDayLaneCount(date) {
@ -180,34 +272,8 @@ import { getToken } from '@/utils/auth';
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
//
return dayEvents.length
},
//
getMaxSingleDayLaneCountBetween(startISO, endISO) {
@ -259,140 +325,7 @@ import { getToken } from '@/utils/auth';
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)
@ -456,26 +389,10 @@ import { getToken } from '@/utils/auth';
return `${event.title}\n时间${this.formatDateTime(event.start_time)} ~ ${this.formatDateTime(event.end_time)}`
}
},
getContinuousEvents() {
getCalendarSegments() {
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 segments = []
const currentMonth = this.calendarDate.getMonth()
const currentYear = this.calendarDate.getFullYear()
@ -501,25 +418,19 @@ import { getToken } from '@/utils/auth';
return end
}
multiDayEvents.forEach(ev => {
// 1
;(this.list || []).forEach(ev => {
const eventStart = this.parseDateTime(ev.start_time)
const eventEndRaw = this.parseDateTime(ev.end_time)
const eventEndRaw = ev.end_time ? this.parseDateTime(ev.end_time) : eventStart
if (!eventStart || !eventEndRaw) return
const eventEnd = adjustEndForDisplay(eventEndRaw)
const normalizedEnd = eventEnd < eventStart ? eventStart : eventEnd
if (eventEnd < monthStart || eventStart > monthEnd) return
if (normalizedEnd < 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
})
}
const clampedEnd = normalizedEnd > monthEnd ? monthEnd : normalizedEnd
let cursor = getWeekStart(clampedStart)
while (cursor <= clampedEnd) {
@ -542,7 +453,7 @@ import { getToken } from '@/utils/auth';
const displayStartCol = (adjStart.getDay() - FIRST_DOW + 7) % 7
const displayEndCol = displayStartCol + spanCols - 1
continuousEvents.push({
segments.push({
...ev,
weekStartISO: weekStart.toISOString(),
segStartISO: segStart.toISOString(),
@ -556,8 +467,7 @@ import { getToken } from '@/utils/auth';
displayStartCol,
displayEndCol,
laneIndex: 0,
//
hasSingleDayConflict: hasSingleDayConflict
isSegment: true
})
}
cursor.setDate(cursor.getDate() + 7)
@ -566,7 +476,7 @@ import { getToken } from '@/utils/auth';
// lane
const byWeek = {}
continuousEvents.forEach(seg => {
segments.forEach(seg => {
const key = seg.displayWeekStartISO || seg.weekStartISO
if (!byWeek[key]) byWeek[key] = []
byWeek[key].push(seg)
@ -598,7 +508,7 @@ import { getToken } from '@/utils/auth';
})
})
return continuousEvents
return segments
},
getContinuousEventClass(event) {
const baseClass = 'continuous-event'
@ -632,8 +542,8 @@ import { getToken } from '@/utils/auth';
const cellWidth = 100 / 7
const cellHeight = 120 //
const overlayBaseTop = 50 // CSS .continuous-events-overlay
const dateNumberHeight = 8 //
const overlayBaseTop = 0 //
const dateNumberHeight = 0
const eventHeight = 20
const eventSpacing = 3
@ -645,15 +555,12 @@ import { getToken } from '@/utils/auth';
? this.weekRowTops[weekRow]
: (weekRow * cellHeight)
//
const singleDayLanes = this.getMaxSingleDayLaneCountBetween(event.segStartISO, event.segEndISO)
const singleDayStackPx = singleDayLanes * (16 + 2) //
const safeGap = 0 // 使
const safeGap = 0
return {
position: 'absolute',
left: `calc(${startColAdjusted * cellWidth}% + 1px)`,
top: `${overlayBaseTop + measuredRowTop + dateNumberHeight + safeGap + singleDayStackPx + verticalOffset}px`, // +
top: `${overlayBaseTop + measuredRowTop + dateNumberHeight + safeGap + verticalOffset}px`,
width: `calc(${event.spanCols * cellWidth}% - 2px)`,
height: `${eventHeight}px`,
zIndex: 500, //
@ -920,6 +827,17 @@ import { getToken } from '@/utils/auth';
position: relative;
}
.calendar-panel ::v-deep .el-calendar__button-group .month-events-btn {
margin-left: 8px;
}
.month-events-summary {
margin-bottom: 10px;
color: #303133;
font-size: 14px;
font-weight: 500;
}
/* 不再需要覆盖层,跨天条直接渲染为 wrapper 的绝对定位子元素 */
.continuous-event {
@ -990,118 +908,13 @@ import { getToken } from '@/utils/auth';
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-"] {
/* 默认样式,会被具体的颜色类覆盖 */
}
.event-list { display: none; }
/* 连续事件的动态颜色支持 */
.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;
}

@ -1060,8 +1060,16 @@ export default {
})
})
//
const groupedValues = Object.values(groupedData)
// sort
const courseTypeOrderMap = new Map(
(this.courseTypeList || []).map((item, index) => [item.name, index])
)
const groupedValues = Object.values(groupedData).sort((a, b) => {
const orderA = courseTypeOrderMap.has(a.courseSystem) ? courseTypeOrderMap.get(a.courseSystem) : Number.MAX_SAFE_INTEGER
const orderB = courseTypeOrderMap.has(b.courseSystem) ? courseTypeOrderMap.get(b.courseSystem) : Number.MAX_SAFE_INTEGER
if (orderA !== orderB) return orderA - orderB
return String(a.courseSystem || '').localeCompare(String(b.courseSystem || ''), 'zh-Hans-CN', { numeric: true })
})
this.courseDetailData = []
groupedValues.forEach(group => {
//
@ -1159,7 +1167,9 @@ export default {
try {
const res = await courseTypeIndex({
page: 1,
page_size: 999
page_size: 999,
sort_name: 'sort',
sort_type: 'ASC'
})
if (res && res.data) {
this.courseTypeList = res.data

Loading…
Cancel
Save