forked from liweijie/education
942 lines
32 KiB
Vue
942 lines
32 KiB
Vue
<template>
|
||
<div class="school-calendar-editor">
|
||
<!-- 标题栏 -->
|
||
<div class="sce-titlebar">
|
||
<div class="sce-title-left">
|
||
<span class="sce-semester-name">{{ semesterName }}</span>
|
||
</div>
|
||
<div class="sce-title-center">
|
||
<span class="sce-date-range">从 {{ dateRangeText }} ,共 {{ totalWeeks }} 周</span>
|
||
</div>
|
||
<div class="sce-title-right">
|
||
<i class="el-icon-close" title="关闭" @click="handleClose" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设置面板 -->
|
||
<div class="sce-panel">
|
||
<div class="sce-panel-row">
|
||
<span class="sce-label">事件名称:</span>
|
||
<el-input v-model="toolbar.eventName" size="small" class="sce-event-input" :placeholder="mode === 'class' ? '请输入班历事件名称' : '请输入校历事件名称'" />
|
||
<el-checkbox v-model="toolbar.bold" class="sce-cb">加粗显示</el-checkbox>
|
||
<el-checkbox v-model="toolbar.schedulable" class="sce-cb">可排课</el-checkbox>
|
||
<el-checkbox v-model="toolbar.mainCourse" class="sce-cb">正课</el-checkbox>
|
||
<el-checkbox v-model="toolbar.remarkShow" class="sce-cb">备注显示</el-checkbox>
|
||
<span class="sce-label">备注:</span>
|
||
<el-input
|
||
v-model="toolbar.remark"
|
||
size="small"
|
||
type="textarea"
|
||
:rows="1"
|
||
class="sce-remark-input"
|
||
resize="none"
|
||
placeholder="备注信息"
|
||
/>
|
||
</div>
|
||
<div class="sce-panel-row">
|
||
<el-button type="primary" size="small" icon="el-icon-check" @click="applyEvent">设定</el-button>
|
||
<el-button type="danger" size="small" icon="el-icon-delete" @click="deleteEvent">删除</el-button>
|
||
<el-button size="small" icon="el-icon-brush" @click="absorbEvent">吸取</el-button>
|
||
<el-button size="small" icon="el-icon-refresh" @click="refreshGrid">刷新</el-button>
|
||
<span class="sce-divider" />
|
||
<el-checkbox v-model="show78" class="sce-cb">显示78节</el-checkbox>
|
||
<el-checkbox v-model="showNight" class="sce-cb">显示晚上</el-checkbox>
|
||
<el-checkbox v-model="showLateNight" class="sce-cb">显示夜间</el-checkbox>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 时间编排区域 -->
|
||
<div ref="gridWrap" class="sce-grid-wrap" @mousedown="onGridMouseDown">
|
||
<table class="sce-grid" :class="{ 'is-selecting': selecting }">
|
||
<thead>
|
||
<tr>
|
||
<th class="sce-corner sce-th-weekno" :rowspan="2">周次</th>
|
||
<th class="sce-corner sce-th-weekrange" :rowspan="2">日期段</th>
|
||
<th v-for="col in visibleColumns" :key="'h1-' + col.colIndex" :colspan="1" class="sce-th-day">
|
||
{{ weekDayLabel(col.dayIndex) }}
|
||
</th>
|
||
</tr>
|
||
<tr>
|
||
<th v-for="col in visibleColumns" :key="'h2-' + col.colIndex" class="sce-th-slot">
|
||
{{ col.slotLabel }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(week, wIdx) in weeks" :key="'w-' + wIdx">
|
||
<td class="sce-week-cell sce-weekno-cell">{{ wIdx + 1 }}</td>
|
||
<td class="sce-week-cell sce-weekrange-cell">{{ week.rangeText }}</td>
|
||
<td
|
||
v-for="col in visibleColumns"
|
||
:key="'c-' + wIdx + '-' + col.colIndex"
|
||
class="sce-cell"
|
||
:class="cellClass(wIdx, col)"
|
||
:data-key="cellKey(wIdx, col.colIndex)"
|
||
@dblclick="onCellDblClick(wIdx, col)"
|
||
>
|
||
<span v-if="getEvent(wIdx, col.colIndex) && getEvent(wIdx, col.colIndex).name" class="sce-event-name" :class="{ 'is-bold': getEvent(wIdx, col.colIndex).bold }">{{ getEvent(wIdx, col.colIndex).name }}</span>
|
||
<span v-else class="sce-date-num">{{ dateNumberOf(wIdx, col.dayIndex) }}</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="sce-status">
|
||
已选 {{ selectedKeys.length }} 个时间格
|
||
<span v-if="absorbPreview" class="sce-absorb-tip">已吸取:{{ absorbPreview }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { getSemester } from '@/api/teachBusiness/semester'
|
||
import { listXqxlb, addXqxlb, updateXqxlb } from '@/api/teachBusiness/xqxlb'
|
||
import { listClassCalendarEvent, updateClassCalendarEvent } from '@/api/teachOffice/classCalendarEvent'
|
||
|
||
// 节次定义:12/34/56 常显,78/晚上/夜间由开关控制可见
|
||
const SLOT_DEFS = [
|
||
{ key: '12', label: '1-2', ctrl: null },
|
||
{ key: '34', label: '3-4', ctrl: null },
|
||
{ key: '56', label: '5-6', ctrl: null },
|
||
{ key: '78', label: '7-8', ctrl: 'show78' },
|
||
{ key: 'night', label: '晚上', ctrl: 'showNight' },
|
||
{ key: 'late', label: '夜间', ctrl: 'showLateNight' }
|
||
]
|
||
|
||
// 前端节次 key -> 后端 xqxlb.courseClass 节次范围(如 12 节 -> "1-2"、夜间 -> "11-12")
|
||
const SLOT_COURSE_MAP = {
|
||
'12': '1-2',
|
||
'34': '3-4',
|
||
'56': '5-6',
|
||
'78': '7-8',
|
||
night: '9-10',
|
||
late: '11-12'
|
||
}
|
||
|
||
const WEEK_DAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||
|
||
function pad2(n) {
|
||
return n < 10 ? '0' + n : '' + n
|
||
}
|
||
|
||
function fmtDate(d) {
|
||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
|
||
}
|
||
|
||
// 生成 32 位小写 UUID(去横线),与后端 UuidUtil.getUUID 一致,用于 bh 缺失时兜底
|
||
function genUUID() {
|
||
let s = ''
|
||
const chars = '0123456789abcdef'
|
||
for (let i = 0; i < 32; i++) {
|
||
s += chars[Math.floor(Math.random() * 16)]
|
||
}
|
||
return s
|
||
}
|
||
|
||
export default {
|
||
name: 'SchoolCalendarEditor',
|
||
props: {
|
||
nd: { type: [String, Number], default: '' },
|
||
semesterName: { type: String, default: '' },
|
||
mode: { type: String, default: 'school' },
|
||
xydxqbh: { type: String, default: '' },
|
||
xydbh: { type: String, default: '' },
|
||
rangeStart: { type: String, default: '' },
|
||
rangeEnd: { type: String, default: '' }
|
||
},
|
||
data() {
|
||
return {
|
||
// 学期日期范围
|
||
startDate: null,
|
||
endDate: null,
|
||
// 日历渲染起点(学期当周周一)
|
||
calendarStart: null,
|
||
totalWeeks: 0,
|
||
dateRangeText: '',
|
||
weeks: [],
|
||
// 显示开关(默认不勾选,每天仅显示 1-2/3-4/5-6 三个节次)
|
||
show78: false,
|
||
showNight: false,
|
||
showLateNight: false,
|
||
// 工具栏表单
|
||
toolbar: {
|
||
eventName: '',
|
||
bold: false,
|
||
schedulable: false,
|
||
mainCourse: false,
|
||
remarkShow: false,
|
||
remark: ''
|
||
},
|
||
// 事件存储:key = cellKey -> event
|
||
events: {},
|
||
// 暂存被隐藏节次列上的事件:key = 周-星期-节次 -> event,列再次显示时恢复
|
||
hiddenEvents: {},
|
||
// 选择集(用数组以保证 Vue2 响应式)
|
||
selectedKeys: [],
|
||
// 拖拽状态
|
||
selecting: false,
|
||
dragStart: null,
|
||
dragStartSelected: false, // 按下起点在按下前是否已选中
|
||
moved: false, // 是否发生移动(区分点击与拖拽)
|
||
dragAddMode: false, // ctrl 拖拽为追加
|
||
// 窗口控制
|
||
absorbPreview: ''
|
||
}
|
||
},
|
||
computed: {
|
||
// 当前可见列(由开关过滤,默认每天三个节次)
|
||
visibleColumns() {
|
||
const cols = []
|
||
let colIndex = 0
|
||
WEEK_DAYS.forEach((dayLabel, dayIndex) => {
|
||
SLOT_DEFS.forEach(slot => {
|
||
if (slot.ctrl && !this[slot.ctrl]) return
|
||
cols.push({ colIndex: colIndex, dayIndex: dayIndex, slotKey: slot.key, slotLabel: slot.label, dayLabel: dayLabel })
|
||
colIndex++
|
||
})
|
||
})
|
||
return cols
|
||
},
|
||
// xqxlb 的 nd 后端已统一为「6 位学期代号」(如 202701),与 this.nd 一致。
|
||
// 不再按日历起点/季度取年份,避免跨年度学期解析出错。
|
||
xqxlbNd() {
|
||
return Number(String(this.nd).slice(0, 6)) || Number(this.nd) || 0
|
||
}
|
||
},
|
||
watch: {
|
||
nd: {
|
||
immediate: true,
|
||
handler(val) {
|
||
if (this.mode !== 'class' && val) this.loadSemester()
|
||
}
|
||
},
|
||
xydxqbh: {
|
||
immediate: true,
|
||
handler(val) {
|
||
if (this.mode === 'class' && val) this.loadClassCalendar()
|
||
}
|
||
},
|
||
visibleColumns(newCols, oldCols) {
|
||
// 节次列显隐会改变列索引,按「周期-星期-节次」重定位事件,避免事件随时间格列偏移
|
||
if (oldCols && oldCols.length) {
|
||
const oldPosOf = index => (oldCols[index] || null)
|
||
const newIndex = (dayIndex, slotKey) => {
|
||
const c = newCols.find(x => x.dayIndex === dayIndex && x.slotKey === slotKey)
|
||
return c ? c.colIndex : -1
|
||
}
|
||
const next = {}
|
||
Object.keys(this.events).forEach(key => {
|
||
const m = key.match(/^w(\d+)-c(\d+)$/)
|
||
if (!m) return
|
||
const wIdx = +m[1]
|
||
const old = oldPosOf(+m[2])
|
||
if (!old) return
|
||
const newC = newIndex(old.dayIndex, old.slotKey)
|
||
if (newC < 0) {
|
||
// 该节次列被隐藏:事件暂存,待列再次显示时恢复
|
||
this.hiddenEvents[this.cellStableKey(wIdx, old.dayIndex, old.slotKey)] = this.events[key]
|
||
return
|
||
}
|
||
next['w' + wIdx + '-c' + newC] = this.events[key]
|
||
})
|
||
// 恢复重新显示的隐藏节次事件
|
||
Object.keys(this.hiddenEvents).forEach(stable => {
|
||
const p = stable.split('-')
|
||
const wIdx = +p[0]
|
||
const dayIndex = +p[1]
|
||
const slotKey = p[2]
|
||
const newC = newIndex(dayIndex, slotKey)
|
||
if (newC >= 0) {
|
||
next['w' + wIdx + '-c' + newC] = this.hiddenEvents[stable]
|
||
delete this.hiddenEvents[stable]
|
||
}
|
||
})
|
||
this.events = next
|
||
}
|
||
// 列变化后清空无效选择(列索引含义会变)
|
||
this.selectedKeys = []
|
||
}
|
||
},
|
||
beforeDestroy() {
|
||
this.removeGlobalListeners()
|
||
},
|
||
methods: {
|
||
/* ---------- 数据加载 ---------- */
|
||
loadClassCalendar() {
|
||
const start = (this.rangeStart || '').slice(0, 10)
|
||
const end = (this.rangeEnd || '').slice(0, 10)
|
||
if (!start || !end) {
|
||
this.$message.warning('班次学期缺少开学或结束日期')
|
||
return
|
||
}
|
||
this.startDate = new Date(start.replace(/-/g, '/'))
|
||
this.endDate = new Date(end.replace(/-/g, '/'))
|
||
this.calendarStart = null
|
||
this.buildWeeks()
|
||
this.loadClassEvents()
|
||
},
|
||
loadClassEvents() {
|
||
listClassCalendarEvent(this.xydxqbh).then(res => {
|
||
const data = res.data
|
||
const list = Array.isArray(data) ? data : (data && data.records) || []
|
||
this.renderEventList(list)
|
||
}).catch(() => {
|
||
this.$message.warning('班历加载失败')
|
||
})
|
||
},
|
||
loadSemester() {
|
||
if (!this.nd) return
|
||
getSemester(this.nd)
|
||
.then(res => {
|
||
const data = res.data || res || {}
|
||
const kx = (data.kxrq || '').toString().slice(0, 10)
|
||
const jx = (data.jsrq || '').toString().slice(0, 10)
|
||
if (kx && jx) {
|
||
this.startDate = new Date(kx.replace(/-/g, '/'))
|
||
this.endDate = new Date(jx.replace(/-/g, '/'))
|
||
this.calendarStart = null
|
||
this.buildWeeks()
|
||
}
|
||
this.loadXqxlbEvents()
|
||
})
|
||
.catch(() => {
|
||
this.$message.warning('学期详情获取失败,请确认学期数据')
|
||
})
|
||
},
|
||
// 从后端拉取本学期校历事件,渲染到对应时间格。
|
||
// XQXLB 的 nd 存 6 位学期代号(如 202601),与 this.xqxlbNd 一致;
|
||
// 后端 updateById 只能改已存在的 bh,所以每个格子必须有预生成的记录才能存。
|
||
// 每次进入都调一次 /xqxlb/add 补齐网格:后端已幂等,只会插入缺失的格子,
|
||
// 既不重复插入也不覆盖已有标记。这样即便区间只被部分覆盖,
|
||
// 也不会出现「格子没有 bh → /xqxlb/update 静默失效」。
|
||
loadXqxlbEvents() {
|
||
const fetchList = () => listXqxlb({ nd: this.xqxlbNd }).then(res => {
|
||
const data = res.data
|
||
return Array.isArray(data) ? data : (data && data.records) || []
|
||
})
|
||
const ensureGrid = () => {
|
||
if (!this.startDate || !this.endDate) {
|
||
return Promise.resolve()
|
||
}
|
||
return addXqxlb(fmtDate(this.startDate), fmtDate(this.endDate), this.xqxlbNd)
|
||
}
|
||
ensureGrid().then(fetchList).then(list => {
|
||
this.renderEventList(list)
|
||
}).catch(() => {
|
||
this.$message.warning('校历事件加载失败')
|
||
})
|
||
},
|
||
renderEventList(list) {
|
||
const unique = {}
|
||
list.forEach(item => {
|
||
const pos = this.locateXqxlb(item)
|
||
if (!pos) return
|
||
const stable = this.cellStableKey(pos.wIdx, pos.dayIndex, pos.slotKey)
|
||
const prev = unique[stable]
|
||
if (!prev || (item.jqmc && !prev.item.jqmc)) {
|
||
unique[stable] = { item: item, pos: pos }
|
||
}
|
||
})
|
||
const loaded = {}
|
||
Object.keys(unique).forEach(stable => {
|
||
const { item, pos } = unique[stable]
|
||
const ev = {
|
||
bh: item.bh,
|
||
name: item.jqmc || '',
|
||
bold: !!item.jc,
|
||
schedulable: !!item.kpk,
|
||
autoSchedule: !!item.zdpk,
|
||
mainCourse: !!item.zk,
|
||
remarkShow: !!item.bzxs,
|
||
remark: item.bz || ''
|
||
}
|
||
const colIndex = this.colIndexOf(pos.dayIndex, pos.slotKey)
|
||
if (colIndex >= 0) {
|
||
loaded[this.cellKey(pos.wIdx, colIndex)] = ev
|
||
} else {
|
||
this.hiddenEvents[this.cellStableKey(pos.wIdx, pos.dayIndex, pos.slotKey)] = ev
|
||
}
|
||
})
|
||
this.events = loaded
|
||
},
|
||
// 根据假期记录反推时间格位置(jqsj 日期 + courseClass 节次),与列是否可见无关
|
||
locateXqxlb(item) {
|
||
if (!this.calendarStart || !item.jqsj) return null
|
||
const d = new Date(String(item.jqsj).slice(0, 10).replace(/-/g, '/'))
|
||
if (isNaN(d.getTime())) return null
|
||
const offset = Math.round((d - this.calendarStart) / (24 * 3600 * 1000))
|
||
if (offset < 0) return null
|
||
const wIdx = Math.floor(offset / 7)
|
||
const dayIndex = offset % 7
|
||
const slotKey = this.courseClassToSlotKey(item.courseClass)
|
||
if (slotKey === null) return null
|
||
return { wIdx, dayIndex, slotKey }
|
||
},
|
||
// 后端 courseClass 节次范围 -> 前端节次 key
|
||
courseClassToSlotKey(courseClass) {
|
||
if (!courseClass) return null
|
||
const s = String(courseClass).trim()
|
||
for (const key in SLOT_COURSE_MAP) {
|
||
if (s === SLOT_COURSE_MAP[key]) return key
|
||
}
|
||
// 兼容 "910"/"1112" 等紧凑写法
|
||
if (s === '910') return 'night'
|
||
if (s === '1112') return 'late'
|
||
return null
|
||
},
|
||
// 获取日期所在周的周一(周一为一周起点)
|
||
getMonday(d) {
|
||
const date = new Date(d)
|
||
const day = date.getDay() // 0=周日,1=周一,...,6=周六
|
||
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
|
||
date.setDate(diff)
|
||
return date
|
||
},
|
||
buildWeeks() {
|
||
if (!this.startDate || !this.endDate) return
|
||
const start = this.getMonday(new Date(this.startDate))
|
||
const end = new Date(this.endDate)
|
||
this.calendarStart = start
|
||
const days = Math.round((end - start) / (24 * 3600 * 1000)) + 1
|
||
this.totalWeeks = Math.ceil(days / 7)
|
||
this.dateRangeText = fmtDate(new Date(this.startDate)) + ' 到 ' + fmtDate(new Date(this.endDate))
|
||
const weeks = []
|
||
for (let w = 0; w < this.totalWeeks; w++) {
|
||
const wkStart = new Date(start)
|
||
wkStart.setDate(start.getDate() + w * 7)
|
||
const wkEnd = new Date(wkStart)
|
||
wkEnd.setDate(wkStart.getDate() + 6)
|
||
weeks.push({
|
||
rangeText: pad2(wkStart.getMonth() + 1) + '-' + pad2(wkStart.getDate()) +
|
||
'至' + pad2(wkEnd.getMonth() + 1) + '-' + pad2(wkEnd.getDate())
|
||
})
|
||
}
|
||
this.weeks = weeks
|
||
},
|
||
colIndexOf(dayIndex, slotKey) {
|
||
const col = this.visibleColumns.find(c => c.dayIndex === dayIndex && c.slotKey === slotKey)
|
||
return col ? col.colIndex : -1
|
||
},
|
||
/* ---------- 单元格工具 ---------- */
|
||
cellKey(wIdx, colIndex) {
|
||
return 'w' + wIdx + '-c' + colIndex
|
||
},
|
||
// 稳定的单元格身份:不随列显隐变化的周-星期-节次
|
||
cellStableKey(wIdx, dayIndex, slotKey) {
|
||
return wIdx + '-' + dayIndex + '-' + slotKey
|
||
},
|
||
getEvent(wIdx, colIndex) {
|
||
return this.events[this.cellKey(wIdx, colIndex)] || null
|
||
},
|
||
weekDayLabel(dayIndex) {
|
||
return WEEK_DAYS[dayIndex]
|
||
},
|
||
// 单元格内显示的日期数字(如 0703)
|
||
dateNumberOf(wIdx, dayIndex) {
|
||
if (!this.calendarStart) return ''
|
||
const d = new Date(this.calendarStart)
|
||
d.setDate(this.calendarStart.getDate() + wIdx * 7 + dayIndex)
|
||
return pad2(d.getMonth() + 1) + pad2(d.getDate())
|
||
},
|
||
cellClass(wIdx, col) {
|
||
const classes = []
|
||
if (this.selectedKeys.includes(this.cellKey(wIdx, col.colIndex))) classes.push('is-selected')
|
||
// 班历:班次日期范围外的格子不可排,给出明确视觉提示
|
||
if (this.mode === 'class' && this.isOutOfRange(wIdx, col.dayIndex)) {
|
||
classes.push('is-out-of-range')
|
||
}
|
||
const ev = this.getEvent(wIdx, col.colIndex)
|
||
// 可排课的全部白色;不可排课且事件名非空才标红
|
||
if (ev && ev.name && !ev.schedulable) {
|
||
classes.push('no-schedule')
|
||
}
|
||
return classes
|
||
},
|
||
// 时间格对应的实际日期
|
||
cellDate(wIdx, dayIndex) {
|
||
if (!this.calendarStart) return null
|
||
const d = new Date(this.calendarStart)
|
||
d.setDate(this.calendarStart.getDate() + wIdx * 7 + dayIndex)
|
||
return d
|
||
},
|
||
// 班历只有 [开学日期, 结束日期] 内的格子会落库,范围外的格子无法保存
|
||
isOutOfRange(wIdx, dayIndex) {
|
||
if (!this.startDate || !this.endDate) return false
|
||
const d = this.cellDate(wIdx, dayIndex)
|
||
if (!d) return false
|
||
const at = v => new Date(v.getFullYear(), v.getMonth(), v.getDate()).getTime()
|
||
return at(d) < at(this.startDate) || at(d) > at(this.endDate)
|
||
},
|
||
// 返回所选时间格里落在班次日期范围外的格子数
|
||
countOutOfRangeSelected() {
|
||
let n = 0
|
||
this.selectedKeys.forEach(key => {
|
||
const pos = this.parseCellKey(key)
|
||
if (pos && this.isOutOfRange(pos.wIdx, pos.dayIndex)) n++
|
||
})
|
||
return n
|
||
},
|
||
/* ---------- 点击 / 拖拽多选 ---------- */
|
||
onGridMouseDown(e) {
|
||
const cell = e.target.closest('.sce-cell')
|
||
if (!cell) {
|
||
// 点击空白区(非单元格,含表头、周次列等)取消所有选中
|
||
if (this.selectedKeys.length) this.selectedKeys = []
|
||
return
|
||
}
|
||
e.preventDefault()
|
||
const key = cell.getAttribute('data-key')
|
||
this.dragStart = key
|
||
this.dragAddMode = e.ctrlKey || e.metaKey
|
||
// 记录起点格在按下时的选中状态,用于松手时判定"纯点击"的切换行为
|
||
this.dragStartSelected = this.selectedKeys.includes(key)
|
||
this.moved = false
|
||
this.selecting = true
|
||
// 非 ctrl 模式下,若起点本身已选中(可能准备拖拽扩展),先不清除,避免误清空
|
||
if (!this.dragAddMode && !this.dragStartSelected) this.selectedKeys = []
|
||
this.addGlobalListeners()
|
||
},
|
||
addGlobalListeners() {
|
||
document.addEventListener('mousemove', this.onGridMouseMove)
|
||
document.addEventListener('mouseup', this.onGridMouseUp)
|
||
},
|
||
removeGlobalListeners() {
|
||
document.removeEventListener('mousemove', this.onGridMouseMove)
|
||
document.removeEventListener('mouseup', this.onGridMouseUp)
|
||
},
|
||
onGridMouseMove(e) {
|
||
if (!this.selecting || !this.dragStart) return
|
||
if (!this.moved) this.moved = true
|
||
const el = document.elementFromPoint(e.clientX, e.clientY)
|
||
const cell = el && el.closest ? el.closest('.sce-cell') : null
|
||
if (!cell) return
|
||
const curKey = cell.getAttribute('data-key')
|
||
this.selectRectangle(this.dragStart, curKey, this.dragAddMode)
|
||
},
|
||
onGridMouseUp() {
|
||
if (!this.selecting) {
|
||
this.removeGlobalListeners()
|
||
return
|
||
}
|
||
// 纯点击(未移动)时,做单选 / 切换
|
||
if (!this.moved) {
|
||
const key = this.dragStart
|
||
if (this.dragAddMode) {
|
||
// Ctrl + 单击:切换该格选中
|
||
if (this.selectedKeys.includes(key)) {
|
||
this.selectedKeys = this.selectedKeys.filter(k => k !== key)
|
||
} else {
|
||
this.selectedKeys.push(key)
|
||
}
|
||
} else {
|
||
// 普通单击:已选中则取消该格,未选中则仅选中该格
|
||
if (this.dragStartSelected) {
|
||
this.selectedKeys = this.selectedKeys.filter(k => k !== key)
|
||
} else {
|
||
this.selectedKeys = [key]
|
||
}
|
||
}
|
||
}
|
||
this.selecting = false
|
||
this.dragStart = null
|
||
this.dragStartSelected = false
|
||
this.removeGlobalListeners()
|
||
},
|
||
// 根据起点终点 key 选中矩形范围内所有格
|
||
selectRectangle(startKey, endKey, addMode) {
|
||
const parse = k => {
|
||
const m = k.match(/^w(\d+)-c(\d+)$/)
|
||
return m ? { w: +m[1], c: +m[2] } : null
|
||
}
|
||
const s = parse(startKey)
|
||
const t = parse(endKey)
|
||
if (!s || !t) return
|
||
if (!addMode) this.selectedKeys = []
|
||
const minW = Math.min(s.w, t.w)
|
||
const maxW = Math.max(s.w, t.w)
|
||
const minC = Math.min(s.c, t.c)
|
||
const maxC = Math.max(s.c, t.c)
|
||
for (let w = minW; w <= maxW; w++) {
|
||
for (let c = minC; c <= maxC; c++) {
|
||
const key = this.cellKey(w, c)
|
||
if (this.selectedKeys.indexOf(key) === -1) this.selectedKeys.push(key)
|
||
}
|
||
}
|
||
},
|
||
/* ---------- 事件操作 ---------- */
|
||
applyEvent() {
|
||
if (!this.selectedKeys.length) {
|
||
this.$message.warning('请先选择时间格')
|
||
return
|
||
}
|
||
const name = this.toolbar.eventName.trim()
|
||
// 设定时未填写事件名:提示,不触发清除
|
||
if (!name) {
|
||
this.$message.warning('请输入事件名')
|
||
return
|
||
}
|
||
// 班历只在班次日期范围内落库,范围外的格子直接提示,避免落成含糊的“保存失败”
|
||
if (this.mode === 'class') {
|
||
const outside = this.countOutOfRangeSelected()
|
||
if (outside > 0) {
|
||
this.$message.warning(`所选 ${outside} 个时间格在班次日期范围外,不可排课`)
|
||
return
|
||
}
|
||
}
|
||
const keys = [...this.selectedKeys]
|
||
keys.forEach(key => {
|
||
const prev = this.events[key]
|
||
this.$set(this.events, key, {
|
||
// 编辑已有事件:沿用后端返回的 bh;新增事件:bh 由 loadXqxlbEvents
|
||
// 通过 /xqxlb/add 预生成的占位行提供(首次进入学期时已落库)
|
||
bh: prev && prev.bh ? prev.bh : undefined,
|
||
name: name,
|
||
bold: this.toolbar.bold,
|
||
schedulable: this.toolbar.schedulable,
|
||
autoSchedule: !!(prev && prev.autoSchedule),
|
||
mainCourse: this.toolbar.mainCourse,
|
||
remarkShow: this.toolbar.remarkShow,
|
||
remark: this.toolbar.remark
|
||
})
|
||
})
|
||
this.persistEvents(keys)
|
||
},
|
||
deleteEvent() {
|
||
if (!this.selectedKeys.length) {
|
||
this.$message.warning('请先选择时间格')
|
||
return
|
||
}
|
||
this.$confirm('确认删除选中单元格的事件吗?', '提示', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
}).then(() => {
|
||
const keys = [...this.selectedKeys]
|
||
this.persistDeleteEvents(keys).then(({ ok, fail }) => {
|
||
if (fail === 0) {
|
||
keys.forEach(key => {
|
||
this.$delete(this.events, key)
|
||
})
|
||
this.selectedKeys = []
|
||
}
|
||
})
|
||
}).catch(() => {})
|
||
},
|
||
absorbEvent() {
|
||
// 吸取:取选择中第一个事件名非空的格
|
||
let target = null
|
||
for (const key of this.selectedKeys) {
|
||
if (this.events[key] && this.events[key].name) { target = this.events[key]; break }
|
||
}
|
||
if (!target) {
|
||
this.$message.warning('所选时间格中无事件可吸取')
|
||
return
|
||
}
|
||
this.toolbar.eventName = target.name
|
||
this.toolbar.bold = !!target.bold
|
||
this.toolbar.schedulable = !!target.schedulable
|
||
this.toolbar.mainCourse = !!target.mainCourse
|
||
this.toolbar.remarkShow = !!target.remarkShow
|
||
this.toolbar.remark = target.remark || ''
|
||
this.absorbPreview = target.name
|
||
},
|
||
onCellDblClick(wIdx, col) {
|
||
const ev = this.getEvent(wIdx, col.colIndex)
|
||
if (ev && ev.name) {
|
||
this.toolbar.eventName = ev.name
|
||
this.toolbar.bold = !!ev.bold
|
||
this.toolbar.schedulable = !!ev.schedulable
|
||
this.toolbar.mainCourse = !!ev.mainCourse
|
||
this.toolbar.remarkShow = !!ev.remarkShow
|
||
this.toolbar.remark = ev.remark || ''
|
||
this.absorbPreview = ev.name
|
||
this.$message.info('已吸取事件:' + ev.name)
|
||
}
|
||
},
|
||
refreshGrid() {
|
||
this.selectedKeys = []
|
||
this.absorbPreview = ''
|
||
this.events = {}
|
||
this.hiddenEvents = {}
|
||
if (this.mode === 'class') this.loadClassEvents()
|
||
else this.loadXqxlbEvents()
|
||
this.$message.info('已刷新')
|
||
},
|
||
reloadEvents() {
|
||
this.events = {}
|
||
this.hiddenEvents = {}
|
||
if (this.mode === 'class') this.loadClassEvents()
|
||
else this.loadXqxlbEvents()
|
||
},
|
||
savePayload(payload) {
|
||
return this.mode === 'class' ? updateClassCalendarEvent(payload) : updateXqxlb(payload)
|
||
},
|
||
/* ---------- 后端持久化(xqxlb 接口) ---------- */
|
||
// 批量保存选中格事件,返回各格子保存成功/失败的个数
|
||
persistEvents(keys) {
|
||
const tasks = keys
|
||
.filter(key => this.events[key])
|
||
.map(key => {
|
||
const payload = this.buildXqxlbPayload(key, this.events[key])
|
||
if (!payload) return Promise.resolve(false)
|
||
return this.savePayload(payload)
|
||
.then(() => true)
|
||
.catch(err => {
|
||
console.warn('[设定] key=', key, '保存失败', err && (err.message || err))
|
||
return false
|
||
})
|
||
})
|
||
return Promise.all(tasks).then(results => {
|
||
const ok = results.filter(r => r === true).length
|
||
const fail = results.length - ok
|
||
if (fail > 0) {
|
||
this.$message.error(fail + ' 个事件保存失败,请检查后端接口')
|
||
} else if (ok > 0) {
|
||
this.$message.success('已设定 ' + ok + ' 个时间格')
|
||
}
|
||
this.reloadEvents()
|
||
return { ok: ok, fail: fail }
|
||
})
|
||
},
|
||
// 批量删除选中格事件:调用 update 接口并将 jqmc 置空完成删除
|
||
persistDeleteEvents(keys) {
|
||
const tasks = keys
|
||
.filter(key => this.events[key])
|
||
.map(key => {
|
||
const payload = this.buildXqxlbPayload(key, this.events[key])
|
||
if (!payload) return Promise.resolve(false)
|
||
payload.jqmc = ''
|
||
return this.savePayload(payload)
|
||
.then(() => true)
|
||
.catch(() => false)
|
||
})
|
||
return Promise.all(tasks).then(results => {
|
||
const ok = results.filter(r => r === true).length
|
||
const fail = results.length - ok
|
||
if (fail > 0) {
|
||
this.$message.error(fail + ' 个事件删除失败,请检查后端接口')
|
||
} else if (ok > 0) {
|
||
this.$message.success('已删除 ' + ok + ' 个时间格事件')
|
||
}
|
||
this.reloadEvents()
|
||
return { ok: ok, fail: fail }
|
||
})
|
||
},
|
||
// 构造 xqxlb 提交数据
|
||
buildXqxlbPayload(key, ev) {
|
||
const pos = this.parseCellKey(key)
|
||
if (!pos) return null
|
||
const d = new Date(this.calendarStart)
|
||
d.setDate(this.calendarStart.getDate() + pos.wIdx * 7 + pos.dayIndex)
|
||
return {
|
||
delFlag: 0,
|
||
// bh 编号必须传:有后端记录则沿用其 bh,否则前端生成 UUID 兜底(避免 payload 缺主键导致 updateById 失效)
|
||
bh: ev.bh || genUUID(),
|
||
// nd 后端已统一为 6 位学期代号(如 202701),与查询口径一致,不再取记录日期的年份
|
||
nd: this.xqxlbNd,
|
||
jqsj: fmtDate(d),
|
||
jqmc: ev.name || '',
|
||
jc: !!ev.bold,
|
||
bz: ev.remark || null,
|
||
kpk: !!ev.schedulable,
|
||
bzxs: !!ev.remarkShow,
|
||
zdpk: !!ev.autoSchedule,
|
||
zk: !!ev.mainCourse,
|
||
courseClass: SLOT_COURSE_MAP[pos.slotKey] || null,
|
||
xydxqbh: this.mode === 'class' ? this.xydxqbh : undefined,
|
||
xydbh: this.mode === 'class' ? this.xydbh : undefined
|
||
}
|
||
},
|
||
selectedEventBhs() {
|
||
return this.selectedKeys
|
||
.map(key => this.events[key] && this.events[key].bh)
|
||
.filter(Boolean)
|
||
},
|
||
// 时间格 key -> { wIdx, dayIndex, slotKey }
|
||
parseCellKey(key) {
|
||
const m = key.match(/^w(\d+)-c(\d+)$/)
|
||
if (!m) return null
|
||
const colIndex = +m[2]
|
||
const col = this.visibleColumns.find(c => c.colIndex === colIndex)
|
||
if (!col) return null
|
||
return { wIdx: +m[1], dayIndex: col.dayIndex, slotKey: col.slotKey }
|
||
},
|
||
/* ---------- 返回学期管理 ---------- */
|
||
handleClose() {
|
||
this.$emit('close')
|
||
if (this.mode !== 'class') {
|
||
this.$router.replace({ path: '/teachBusiness/semester' })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.school-calendar-editor {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
background: #fff;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
|
||
/* 标题栏 */
|
||
.sce-titlebar {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 44px;
|
||
padding: 0 14px;
|
||
background: var(--edu-green-primary, #00875a);
|
||
color: #fff;
|
||
|
||
.sce-title-left { flex: 1; font-size: 15px; font-weight: 600; }
|
||
.sce-title-center { flex: 1; text-align: center; font-size: 13px; opacity: 0.9; }
|
||
.sce-title-right { flex: 0 0 auto; text-align: right; }
|
||
|
||
.sce-title-right i {
|
||
margin-left: 14px;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
opacity: 0.85;
|
||
&:hover { opacity: 1; }
|
||
}
|
||
}
|
||
|
||
/* 设置面板 */
|
||
.sce-panel {
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
background: #fafafa;
|
||
|
||
.sce-panel-row {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 8px;
|
||
|
||
&:last-child { margin-bottom: 0; }
|
||
}
|
||
|
||
.sce-label { font-size: 13px; color: #606266; margin-right: 6px; white-space: nowrap; }
|
||
.sce-event-input { width: 200px; margin-right: 12px; }
|
||
.sce-remark-input { width: 240px; margin-left: 6px; }
|
||
.sce-cb { margin-right: 14px; }
|
||
.sce-divider { width: 1px; height: 18px; background: #dcdfe6; margin: 0 14px; }
|
||
}
|
||
|
||
/* 时间编排区 */
|
||
.sce-grid-wrap {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 8px 10px;
|
||
background: #fff;
|
||
}
|
||
|
||
.sce-grid {
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
width: 100%;
|
||
font-size: 12px;
|
||
user-select: none;
|
||
|
||
th, td {
|
||
border: 1px solid #e4e7ed;
|
||
text-align: center;
|
||
padding: 0;
|
||
}
|
||
|
||
.sce-corner {
|
||
background: #f5f7fa;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.sce-th-weekno { width: 52px; }
|
||
.sce-th-weekrange { width: 110px; }
|
||
|
||
.sce-th-day {
|
||
background: var(--edu-green-primary, #00875a);
|
||
color: #fff;
|
||
font-weight: 600;
|
||
height: 26px;
|
||
}
|
||
|
||
.sce-th-slot {
|
||
background: #ecf5f0;
|
||
color: #00875a;
|
||
height: 22px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.sce-week-cell {
|
||
background: #f5f7fa;
|
||
vertical-align: middle;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.sce-weekno-cell {
|
||
font-weight: 600;
|
||
color: #303133;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.sce-weekrange-cell {
|
||
font-size: 11px;
|
||
color: #909399;
|
||
}
|
||
|
||
.sce-cell {
|
||
height: 30px;
|
||
position: relative;
|
||
cursor: pointer;
|
||
background: #fff;
|
||
transition: background 0.15s;
|
||
|
||
.sce-event-name {
|
||
display: block;
|
||
line-height: 1;
|
||
padding: 2px 0;
|
||
}
|
||
|
||
.sce-date-num {
|
||
display: block;
|
||
font-size: 10px;
|
||
color: #c0c4cc;
|
||
line-height: 1;
|
||
}
|
||
|
||
// 可排课白色,不可排课且有事件标红
|
||
&.no-schedule { background: #fde2e2; }
|
||
|
||
// 班历:班次日期范围外的格子不可排
|
||
&.is-out-of-range {
|
||
background: #f4f4f5;
|
||
cursor: not-allowed;
|
||
|
||
.sce-date-num { color: #dcdfe6; }
|
||
}
|
||
|
||
&.is-selected {
|
||
outline: 2px solid var(--edu-green-primary, #00875a);
|
||
outline-offset: -2px;
|
||
background: #d4ecdf !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
.sce-status {
|
||
padding: 6px 14px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
border-top: 1px solid #ebeef5;
|
||
background: #fafafa;
|
||
|
||
.sce-absorb-tip { margin-left: 12px; color: #00875a; }
|
||
}
|
||
}
|
||
</style>
|