diff --git a/src/views/calendar/index.vue b/src/views/calendar/index.vue index f177493..764c829 100644 --- a/src/views/calendar/index.vue +++ b/src/views/calendar/index.vue @@ -13,29 +13,18 @@
- + - +
+ +
+ 本月开课场次:{{ monthCourseCount }}, + 本月开课天数:{{ monthDayCalendarDisplay }}天 +
+ + + + + + + + + + + + + + + + + + 关闭 + +
@@ -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