master
lion 1 month ago
parent 2ff8b73777
commit 7a3f6a1d86

@ -73,6 +73,7 @@
:disabled="canSelect"
@change="changeCourse"
placeholder="请选择课程"
filterable
clearable
style="width: 100%"
>

@ -3,19 +3,21 @@
<!-- 顶部操作区 -->
<div class="admin-header">
<el-button type="success" icon="el-icon-plus" @click="openCreateModal('add')"></el-button>
</div>
<el-button type="primary" icon="el-icon-plus" @click="exportCalendar"></el-button>
</div>
<!-- 日历预览区 -->
<div class="admin-main">
<div class="calendar-panel">
<div class="calendar-wrapper">
<el-calendar v-model="calendarDate" :first-day-of-week="1">
<template slot="dateCell" slot-scope="{date}">
<div class="cell-content">
<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"
:key="ev.id"
:class="['event-item', getEventClass(ev, date)]"
:style="getEventItemStyle(ev)"
:title="getEventTooltip(ev, date)"
@ -27,19 +29,17 @@
</div>
</template>
</el-calendar>
<!-- 连续事件覆盖层 -->
<div class="continuous-events-overlay">
<div
v-for="event in getContinuousEvents()"
:key="`continuous-${event.id}-${event.segStartISO}`"
:class="['continuous-event', getContinuousEventClass(event)]"
:style="getContinuousEventStyle(event)"
:title="getEventTooltip(event, new Date(event.segStartISO))"
@click.stop="openCreateModal('editor', event.id)"
>
{{ event.title }}
</div>
</div>
<!-- 跨天事件条直接作为 wrapper 的绝对定位子元素渲染避免覆盖层拦截点击 -->
<div
v-for="event in getContinuousEvents()"
:key="`continuous-${event.id}-${event.segStartISO}`"
:class="['continuous-event', getContinuousEventClass(event)]"
:style="getContinuousEventStyle(event)"
:title="getEventTooltip(event, new Date(event.segStartISO))"
@click.stop="openCreateModal('editor', event.id)"
>
{{ event.title }}
</div>
</div>
</div>
</div>
@ -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_timestart_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:00key
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 {

@ -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) {

Loading…
Cancel
Save