+
{{ date.getDate() }}
-
-
-
- {{ 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) {