diff --git a/src/views/calendar/components/addCalendar.vue b/src/views/calendar/components/addCalendar.vue index b75f71b..32083a7 100644 --- a/src/views/calendar/components/addCalendar.vue +++ b/src/views/calendar/components/addCalendar.vue @@ -73,6 +73,7 @@ :disabled="canSelect" @change="changeCourse" placeholder="请选择课程" + filterable clearable style="width: 100%" > diff --git a/src/views/calendar/index.vue b/src/views/calendar/index.vue index 22fdd68..495c157 100644 --- a/src/views/calendar/index.vue +++ b/src/views/calendar/index.vue @@ -3,19 +3,21 @@
新建日历事件 -
+ 导出日历事件 + +
- -
-
- {{ event.title }} -
-
+ +
+ {{ event.title }} +
@@ -53,6 +53,7 @@ import addCalendar from './components/addCalendar.vue' import { index } from '@/api/calendars/index.js' +import { getToken } from '@/utils/auth'; export default { components: { addCalendar @@ -60,7 +61,9 @@ import addCalendar from './components/addCalendar.vue' data() { return { list: [], - calendarDate: new Date() + calendarDate: new Date(), + // 记录每一周行在容器内的实际像素 top,解决不等高行导致定位偏差 + weekRowTops: [] } }, computed: { @@ -73,75 +76,200 @@ import addCalendar from './components/addCalendar.vue' }, watch: { calendarDate: { - handler() { - this.getList() - }, - deep: true + 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() { + console.log('导出日历事件') + const res = await index({ + month: this.selectMonth, + is_export: 1, + 'export_fields[is_publish_text]':'是否对外展示', + '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[content]': '内容' + }) + var url = process.env.VUE_APP_BASE_API + '/api/admin/calendars/index?month=' + this.selectMonth + '&is_export=1&export_fields[is_publish_text]=是否对外展示&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[content]=内容&token=' + getToken() + window.open(url, '_blank') + console.log(res) + }, + onDateCellClick() { + // 阻止 el-calendar 默认点击日期触发的月份切换 + return false + }, async getList() { const res = await index({ month: this.selectMonth }) - this.list = res + // 统一规范化 id,避免 _id 与 id 不一致导致点击与定位异常 + this.list = (res || []).map(e => ({ ...e, id: e.id || e._id })) // 重新生成动态样式 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') { - this.$refs.addCalendar.id = id + addRef.id = finalId } - this.$refs.addCalendar.type = type - this.$refs.addCalendar.isShow = true + 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 events = this.list.filter(ev => { + const currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate()) + const oneDayEvents = []; + + (this.list || []).forEach(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 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 + }) } - - // 单天事件正常显示 - 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() }) - - // 为同一天的事件分配垂直位置,避免重叠 - return this.arrangeEventsVertically(events) + + // 为同一天所有事件分配垂直位置,避免重叠 + return this.arrangeEventsVertically(oneDayEvents) }, // 为同一天的事件分配垂直位置,避免重叠 arrangeEventsVertically(events) { if (!events || events.length === 0) return events - // 按开始时间排序 + // 按开始时间排序(片段无具体时间则按标题/ID兜底) const sortedEvents = events.sort((a, b) => { - const timeA = this.parseDateTime(a.start_time) - const timeB = this.parseDateTime(b.start_time) - return timeA.getTime() - timeB.getTime() + 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)) }) // 为每个事件分配垂直位置 @@ -153,9 +281,10 @@ import addCalendar from './components/addCalendar.vue' const startTime = this.parseDateTime(event.start_time) const endTime = event.end_time ? this.parseDateTime(event.end_time) : startTime - // 计算时间范围(以小时为单位) - const startHour = startTime.getHours() + startTime.getMinutes() / 60 - const endHour = endTime.getHours() + endTime.getMinutes() / 60 + // 片段(跨天按全天)采用 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 @@ -189,8 +318,8 @@ import addCalendar from './components/addCalendar.vue' // 为事件添加垂直位置信息 event.verticalPosition = laneIndex - // 使用bottom定位时,需要从底部开始计算偏移 - event.topOffset = (lanes.length - 1 - laneIndex) * (eventHeight + eventSpacing) + // 自上而下堆叠:从顶部开始计算偏移 + event.topOffset = laneIndex * (eventHeight + eventSpacing) }) return sortedEvents @@ -229,8 +358,8 @@ import addCalendar from './components/addCalendar.vue' if (event.topOffset !== undefined) { return { position: 'relative', - bottom: `${event.topOffset}px`, - zIndex: 70 + (event.verticalPosition || 0) // 确保单天事件在跨天事件之上 + top: `${Math.max(0, event.topOffset - 8)}px`, + zIndex: 70 + (event.verticalPosition || 0) // 单天事件保持在顶层 } } return {} @@ -329,6 +458,8 @@ import addCalendar from './components/addCalendar.vue' 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 } @@ -413,37 +544,28 @@ import addCalendar from './components/addCalendar.vue' }) Object.values(byWeek).forEach(segs => { - // 按开始列排序,开始相同则优先更长的,减少冲突;开始与长度都相同再按 id 稳定排序 + // 排序:开始列优先,其次跨度更长,最后按 id 稳定 segs.sort((a, b) => (a.displayStartCol - b.displayStartCol) || (b.spanCols - a.spanCols) || String(a.id).localeCompare(String(b.id))) - const laneEndCols = [] // 每个轨道当前占据的最后列(显示列) - const placedSegs = [] + // 使用严格区间冲突检测的多区间轨道 + const lanes = [] // 每个元素是该轨道内的区间数组 [{start,end}] segs.forEach(seg => { + let laneIndex = 0 let placed = false - for (let i = 0; i < laneEndCols.length; i += 1) { - // 只要前一个轨道的结束列 < 当前分段的开始列,就可复用该轨道 - if (laneEndCols[i] < seg.displayStartCol) { - seg.laneIndex = i - laneEndCols[i] = seg.displayEndCol + 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) { - seg.laneIndex = laneEndCols.length - laneEndCols.push(seg.displayEndCol) - } - placedSegs.push(seg) - }) - // 同一轨道再做“同起始列”的细分:将完全同起止的切片平移到新轨道 - const seen = {} - placedSegs.forEach(seg => { - const k = `${seg.displayStartCol}-${seg.displayEndCol}-${seg.laneIndex}` - if (seen[k]) { - // 已存在相同起止且同轨道,放到新轨道 - seg.laneIndex = ++seen[k].maxLane - } else { - seen[k] = { maxLane: seg.laneIndex } + laneIndex = lanes.length + lanes.push([{ start: seg.displayStartCol, end: seg.displayEndCol }]) } + seg.laneIndex = laneIndex }) }) @@ -480,27 +602,35 @@ import addCalendar from './components/addCalendar.vue' : (adjStart.getDay() - FIRST_DOW + 7) % 7 const cellWidth = 100 / 7 - const cellHeight = 120 // 调整格子高度,确保有足够空间 - const headerHeight = 50 - const dateNumberHeight = 40 // 调整日期数字高度,确保有足够空间 - const eventHeight = 16 - const eventSpacing = 2 + const cellHeight = 120 // 兜底高度 + const overlayBaseTop = 50 // 与 CSS .continuous-events-overlay 保持一致 + const dateNumberHeight = 8 // 进一步压缩日期占位 + const eventHeight = 20 + const eventSpacing = 3 - // 获取该日期所有事件的垂直位置分配 - const dayEvents = this.getDayEventsWithPositions(adjStart) - const currentEvent = dayEvents.find(ev => ev.id === event.id) - const verticalOffset = currentEvent ? currentEvent.topOffset : 0 + // 使用跨天分段在本周内的 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}% + 2px)`, - top: `${headerHeight + weekRow * cellHeight + dateNumberHeight + 25 + verticalOffset}px`, // 增加偏移量到25px,确保课程标题完全在日期数字下方 - width: `calc(${event.spanCols * cellWidth}% - 4px)`, + left: `calc(${startColAdjusted * cellWidth}% + 1px)`, + top: `${overlayBaseTop + measuredRowTop + dateNumberHeight + safeGap + singleDayStackPx + verticalOffset}px`, // 预留单天事件空间+安全间距 + width: `calc(${event.spanCols * cellWidth}% - 2px)`, height: `${eventHeight}px`, - zIndex: 50, // 降低跨天事件的层级 + zIndex: 500, // 置于表格之上,结合预留空隙不遮挡单天,确保可点 background: `linear-gradient(90deg, ${this.getEventTypeColor(event.type)} 0%, ${this.darkenColor(this.getEventTypeColor(event.type))} 100%)`, borderRadius: '3px', - fontSize: '11px', + fontSize: '12px', lineHeight: `${eventHeight}px`, color: 'white', padding: '0 4px', @@ -761,26 +891,18 @@ import addCalendar from './components/addCalendar.vue' position: relative; } - .continuous-events-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; - z-index: 50; /* 降低跨天事件的层级 */ - } + /* 不再需要覆盖层,跨天条直接渲染为 wrapper 的绝对定位子元素 */ .continuous-event { - pointer-events: auto; + pointer-events: auto; /* 条目本身可点击(覆盖父层 none) */ transition: all 0.2s ease; } - .continuous-event:hover { - transform: translateY(-1px); - filter: brightness(1.1); - z-index: 51; /* 调整悬停时的层级 */ - } +.continuous-event:hover { + transform: translateY(-1px); + filter: brightness(1.1); + z-index: 501; /* 悬停时略高于默认 */ +} /* Element UI 日历样式覆盖 */ .calendar-panel ::v-deep .el-calendar-table { @@ -795,6 +917,12 @@ import addCalendar from './components/addCalendar.vue' 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; @@ -820,10 +948,17 @@ import addCalendar from './components/addCalendar.vue' .date-number { font-weight: bold; - color: #333; - margin-bottom: 2px; - position: relative; - z-index: 1; + 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 { diff --git a/src/views/config/teacher.vue b/src/views/config/teacher.vue index 877ad8f..4d9ddd8 100644 --- a/src/views/config/teacher.vue +++ b/src/views/config/teacher.vue @@ -166,6 +166,7 @@ import { save as saveCourseContent } from '@/api/course/courseContent.js' + import { getToken } from '@/utils/auth'; export default { components: { addTeacher, @@ -262,29 +263,32 @@ value: this.select.name }], page: 1, - page_size: 9999 + page_size: 9999, + is_export: 1 }) - if (res.data) { - let headers = this.table_item.map(i => { - return { - key: i.prop, - title: i.label - } - }) - const data = res.data.map(row => headers.map(header => row[header.key])); - data.unshift(headers.map(header => header.title)); - const wb = XLSX.utils.book_new(); - const ws = XLSX.utils.aoa_to_sheet(data); - XLSX.utils.book_append_sheet(wb, ws, sheetName); - const wbout = XLSX.write(wb, { - bookType: 'xlsx', - bookSST: true, - type: 'array' - }); - saveAs(new Blob([wbout], { - type: 'application/octet-stream' - }), `${sheetName}.xlsx`); - } + var url = process.env.VUE_APP_BASE_API + '/api/admin/teachers/index?is_export=1&page=1&page_size=9999&filter[0][key]=name&filter[0][op]=like&filter[0][value]=' + this.select.name + '&token=' + getToken() + window.open(url, '_blank') + // if (res.data) { + // let headers = this.table_item.map(i => { + // return { + // key: i.prop, + // title: i.label + // } + // }) + // const data = res.data.map(row => headers.map(header => row[header.key])); + // data.unshift(headers.map(header => header.title)); + // const wb = XLSX.utils.book_new(); + // const ws = XLSX.utils.aoa_to_sheet(data); + // XLSX.utils.book_append_sheet(wb, ws, sheetName); + // const wbout = XLSX.write(wb, { + // bookType: 'xlsx', + // bookSST: true, + // type: 'array' + // }); + // saveAs(new Blob([wbout], { + // type: 'application/octet-stream' + // }), `${sheetName}.xlsx`); + // } }, editTeacher(type, id) { if (id) {