-
+
-
+
+
+
+ 本月开课场次:{{ monthCourseCount }},
+ 本月开课天数:{{ monthDayCalendarDisplay }}天
+
+
+
+
+
+
+ {{ scope.row.end_time || '-' }}
+
+
+
+
+ {{ scope.row.is_count_days_text || '-' }}
+
+
+
+
+ {{ scope.row.is_count_people_text || '-' }}
+
+
+
+
+ {{ scope.row.days || '-' }}
+
+
+
+
+
@@ -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 = '
本月事件'
+ 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;
}
diff --git a/src/views/statistics/index.vue b/src/views/statistics/index.vue
index 86c9426..27118ef 100644
--- a/src/views/statistics/index.vue
+++ b/src/views/statistics/index.vue
@@ -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