Files
education/frontend/src/views/teachBusiness/calendar/components/SchoolCalendarEditor.vue
T
liweijie f3ae9fb2fd 1、校历的78、晚上、夜间等直接落库,
2、配当、任务书完善、排课窗完善;
3、任务书批量发布、删除等;
4、班历同步校历
2026-09-18 10:38:39 +08:00

1492 lines
55 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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' ? '请输入班历事件名称' : (mode === 'venue' ? '请输入场地事件名称' : '请输入校历事件名称')" />
<el-checkbox v-if="mode !== 'venue'" v-model="toolbar.bold" class="sce-cb">加粗显示</el-checkbox>
<el-checkbox v-model="toolbar.schedulable" class="sce-cb">可排课</el-checkbox>
<el-checkbox v-if="mode !== 'venue'" v-model="toolbar.autoSchedule" class="sce-cb">自动排课</el-checkbox>
<el-checkbox v-if="mode !== 'venue'" v-model="toolbar.mainCourse" class="sce-cb">正课</el-checkbox>
<el-checkbox v-if="mode !== 'venue'" 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 v-if="mode === 'venue'" size="small" icon="el-icon-magic-stick" @click="openRuleDialog">规律生成</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 v-if="mode === 'venue' && designatedTeams.length" class="sce-venue-banner">
指定本场地为专用教室的班次{{ designatedTeams.join('') }}
</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)"
:title="cellTitle(wIdx, col)"
@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-if="mode === 'venue' && getLessons(wIdx, col.colIndex)" class="sce-lesson-name">{{ lessonTextOf(wIdx, col.colIndex) }}</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>
<span v-if="mode === 'venue'" class="sce-legend">
<i class="lg lg-red" />不可用
<i class="lg lg-yellow" />场地备注
<i class="lg lg-blue" />已排课次
<i class="lg lg-gray" />校历不可排
</span>
</div>
<!-- 场地历按星期规律生成 -->
<el-dialog title="按星期规律生成场地事件" :visible.sync="ruleDialog.visible" width="540px" append-to-body
:close-on-click-modal="false">
<el-form label-width="90px" size="small">
<el-form-item label="星期">
<el-checkbox-group v-model="ruleDialog.weekdays">
<el-checkbox v-for="(d, i) in weekDayOptions" :key="i" :label="i">{{ d }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="节次">
<el-checkbox-group v-model="ruleDialog.slots">
<el-checkbox v-for="s in slotOptions" :key="s.key" :label="s.key">{{ s.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="周次范围">
<el-input-number v-model="ruleDialog.weekStart" :min="1" :max="totalWeeks || 1" size="small" class="rule-week" />
<span class="rule-sep"></span>
<el-input-number v-model="ruleDialog.weekEnd" :min="1" :max="totalWeeks || 1" size="small" class="rule-week" />
<span class="rule-unit"></span>
</el-form-item>
<el-form-item label="事件内容">
<span class="rule-hint">使用上方工具栏填写的事件名称 / 可排课 / 备注</span>
</el-form-item>
<el-form-item label="预计生成">
<span>{{ rulePreviewCount }} 个时间格</span>
<span v-if="ruleSkippedCount" class="rule-hint">跳过校历不可排/已排课 {{ ruleSkippedCount }} </span>
</el-form-item>
</el-form>
<div slot="footer">
<el-button size="small" @click="ruleDialog.visible = false"> </el-button>
<el-button type="primary" size="small" :loading="ruleDialog.submitting" @click="applyRuleGenerate"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getSemester } from '@/api/teachBusiness/semester'
import { listXqxlb, addXqxlb, updateXqxlb } from '@/api/teachBusiness/xqxlb'
import { listClassCalendarEvent, updateClassCalendarEvent, batchSaveClassCalendarEvent } from '@/api/teachOffice/classCalendarEvent'
import {
listVenueCalendarRange,
batchSaveVenueCalendar,
batchDeleteVenueCalendar,
listVenueLessons,
listVenueDesignatedTeams
} from '@/api/teachBusiness/venueCalendar'
import { getSystemSettings, updateSystemSettings, getPeriodSlots } from '@/api/teachBusiness/systemSettings'
// 节次定义回退值:节次时间表为空时使用,12/34/56 常显,78/晚上/夜间由开关控制可见
const DEFAULT_SLOT_DEFS = [
{ key: '12', label: '1-2', courseClass: '1-2', ctrl: null },
{ key: '34', label: '3-4', courseClass: '3-4', ctrl: null },
{ key: '56', label: '5-6', courseClass: '5-6', ctrl: null },
{ key: '78', label: '7-8', courseClass: '7-8', ctrl: 'show78' },
{ key: 'night', label: '晚上', courseClass: '9-10', ctrl: 'showNight' },
{ key: 'late', label: '夜间', courseClass: '11-12', ctrl: 'showLateNight' }
]
// 前端节次 key -> 后端 xqxlb.courseClass 节次范围(如 12 节 -> "1-2"、夜间 -> "11-12"
const SLOT_COURSE_MAP = {}
DEFAULT_SLOT_DEFS.forEach(s => { SLOT_COURSE_MAP[s.key] = s.courseClass })
// 节次范围解析:"1-2"/"7~8" -> [1,2];单数字 -> [n,n];非数字返回 null
function parseSlotRange(label) {
if (label == null) return null
const s = String(label).trim()
const m = s.match(/(\d+)\s*[-~—–]\s*(\d+)/)
if (m) return [+m[1], +m[2]]
if (/^\d+$/.test(s)) return [+s, +s]
return null
}
// 节次段受哪个显示开关控制:前 6 节常显,7-8 -> show789-10 -> showNight11+ -> showLateNight
// 非数字标签按名称/时段推断(晚->晚上,夜->夜间)
function ctrlOfSlot(label, sd) {
const range = parseSlotRange(label)
if (range) {
const first = range[0]
if (first <= 6) return null
if (first <= 8) return 'show78'
if (first <= 10) return 'showNight'
return 'showLateNight'
}
const s = String(label || '') + String(sd || '')
if (s.indexOf('夜') >= 0) return 'showLateNight'
if (s.indexOf('晚') >= 0) return 'showNight'
return null
}
const WEEK_DAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
// 场地历:单节次 -> 节次格 key(1-2 节合并在同一格)
const JC_SLOT_MAP = { 1: '12', 2: '12', 3: '34', 4: '34', 5: '56', 6: '56', 7: '78', 8: '78', 9: 'night', 10: 'night', 11: 'late', 12: 'late' }
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: '' },
// 场地历模式:教室编号
jsbh: { type: String, default: '' },
rangeStart: { type: String, default: '' },
rangeEnd: { type: String, default: '' }
},
data() {
return {
// 学期日期范围
startDate: null,
endDate: null,
// 日历渲染起点(学期当周周一)
calendarStart: null,
totalWeeks: 0,
dateRangeText: '',
weeks: [],
// 显示开关(默认不勾选,挂载时从系统参数 DBVersion 恢复)
show78: false,
showNight: false,
showLateNight: false,
// 系统参数是否已加载(加载完成前的开关赋值不回写)
settingsReady: false,
// 节次列定义:由节次时间表驱动,空表时回退默认六段
slotDefs: DEFAULT_SLOT_DEFS.slice(),
// 工具栏表单
toolbar: {
eventName: '',
bold: false,
schedulable: false,
autoSchedule: 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: '',
// ---- 场地历模式 ----
// 校历不可排格:'yyyy-MM-dd#slotKey' -> 校历事件名(置灰只读,不落库)
schoolOff: {},
// 已排课次(蓝格只读):'yyyy-MM-dd#slotKey' -> [{ kcmc, jyxm, xydmc, jc }]
lessons: {},
// 指定本场地为专用教室的班次(顶部横幅)
designatedTeams: [],
weekDayOptions: WEEK_DAYS,
ruleDialog: {
visible: false,
submitting: false,
weekdays: [],
slots: [],
weekStart: 1,
weekEnd: 1
}
}
},
computed: {
// 规律生成弹窗的节次选项(同节次时间表口径)
slotOptions() {
return this.slotDefs
},
// 当前可见列(由开关过滤,默认每天三个节次)
visibleColumns() {
const cols = []
let colIndex = 0
WEEK_DAYS.forEach((dayLabel, dayIndex) => {
this.slotDefs.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
},
// 规律生成:预计写入的格子数(排除学期范围外/校历不可排/已排课的格)
ruleTargets() {
const cells = []
let skipped = 0
const dlg = this.ruleDialog
if (!dlg.weekdays.length || !dlg.slots.length || !this.calendarStart) return { cells, skipped }
const w0 = Math.max(0, (dlg.weekStart || 1) - 1)
const w1 = Math.min(this.totalWeeks - 1, (dlg.weekEnd || this.totalWeeks) - 1)
for (let w = w0; w <= w1; w++) {
dlg.weekdays.forEach(dayIndex => {
if (this.isOutOfRange(w, dayIndex)) return
const d = this.cellDate(w, dayIndex)
const dateStr = fmtDate(d)
dlg.slots.forEach(slotKey => {
if (this.isSchoolOff(dateStr, slotKey) || this.hasLessonAt(dateStr, slotKey)) {
skipped++
return
}
cells.push({ dateStr, slotKey })
})
})
}
return { cells, skipped }
},
rulePreviewCount() {
return this.ruleTargets.cells.length
},
ruleSkippedCount() {
return this.ruleTargets.skipped
}
},
watch: {
// 显示开关变更即写回系统参数(防抖合并连续变更)
show78() { this.persistDisplaySettings() },
showNight() { this.persistDisplaySettings() },
showLateNight() { this.persistDisplaySettings() },
nd: {
immediate: true,
handler(val) {
if (this.mode !== 'class' && val) this.loadSemester()
}
},
xydxqbh: {
immediate: true,
handler(val) {
if (this.mode === 'class' && val) this.loadClassCalendar()
}
},
jsbh: {
immediate: true,
handler(val) {
if (this.mode === 'venue' && val) this.loadVenueEvents()
}
},
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 = []
}
},
created() {
this.loadDisplaySettings()
this.loadPeriodSlots()
},
beforeDestroy() {
this.removeGlobalListeners()
if (this._settingsTimer) clearTimeout(this._settingsTimer)
},
methods: {
/* ---------- 系统参数 / 节次字典 ---------- */
// 显示开关从 DBVersion 恢复,刷新不丢配置
loadDisplaySettings() {
getSystemSettings().then(res => {
const d = (res && res.data) || {}
const pick = (a, b) => (d[a] !== undefined ? d[a] : d[b])
this.show78 = !!pick('xs78J', 'xs78j')
this.showNight = !!pick('xsws', 'xsws')
this.showLateNight = !!pick('xsyj', 'xsyj')
this.$nextTick(() => { this.settingsReady = true })
}).catch(() => {
this.settingsReady = true
})
},
// 开关变更回写 DBVersion(只提交三个字段,后端只写非空字段不影响其它参数)
persistDisplaySettings() {
if (!this.settingsReady) return
if (this._settingsTimer) clearTimeout(this._settingsTimer)
this._settingsTimer = setTimeout(() => {
updateSystemSettings({
xs78J: this.show78,
xsws: this.showNight,
xsyj: this.showLateNight
}).catch(() => {})
}, 400)
},
// 节次列由节次时间表驱动;空表或拉取失败保留默认六段
loadPeriodSlots() {
getPeriodSlots().then(res => {
const rows = (res && res.data) || []
const defs = rows
.filter(r => r && r.jcsy)
.map(r => ({
key: String(r.jcsy),
label: String(r.jcsy),
courseClass: String(r.jcsy),
ctrl: ctrlOfSlot(r.jcsy, r.sd)
}))
if (defs.length) this.slotDefs = defs
}).catch(() => {})
},
// 节次格 key -> 后端 courseClass 值(动态节次表优先)
courseClassOfSlot(slotKey) {
const s = this.slotDefs.find(x => x.key === slotKey)
return s ? s.courseClass : (SLOT_COURSE_MAP[slotKey] || null)
},
// 单节次 -> 节次格 key(动态范围优先,回退固定 1-12 映射)
jcToSlotKey(jc) {
const n = Number(jc)
if (!n) return null
for (const s of this.slotDefs) {
const r = parseSlotRange(s.courseClass)
if (r && n >= r[0] && n <= r[1]) return s.key
}
const fallback = JC_SLOT_MAP[n]
return this.slotDefs.some(x => x.key === fallback) ? fallback : null
},
/* ---------- 数据加载 ---------- */
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()
}
if (this.mode === 'venue') this.loadVenueEvents()
else 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
},
/* ---------- 场地历(venue 模式) ---------- */
// 场地历为稀疏存储:只拉"事件"行,空格默认即"可排"
// 校历不可排格(节假日)整格置灰只读不落库;已排课次蓝格只读。
loadVenueEvents() {
if (!this.jsbh) return
const s = this.startDate ? fmtDate(this.startDate) : ''
const e = this.endDate ? fmtDate(this.endDate) : ''
Promise.all([
listXqxlb({ nd: this.xqxlbNd }).catch(() => ({ data: [] })),
listVenueCalendarRange(this.jsbh, s, e).catch(() => ({ data: [] })),
listVenueLessons(this.jsbh, s, e, this.xqxlbNd).catch(() => ({ data: [] })),
listVenueDesignatedTeams(this.jsbh).catch(() => ({ data: [] }))
]).then(([xlRes, evRes, lsRes, tmRes]) => {
const xlList = Array.isArray(xlRes.data) ? xlRes.data : (xlRes.data && xlRes.data.records) || []
const evList = Array.isArray(evRes.data) ? evRes.data : (evRes.data && evRes.data.records) || []
const lsList = Array.isArray(lsRes.data) ? lsRes.data : (lsRes.data && lsRes.data.records) || []
this.designatedTeams = Array.isArray(tmRes.data) ? tmRes.data : []
// 校历不可排格 -> 灰
const off = {}
xlList.forEach(item => {
if (item.kpk === true || item.kpk === 1) return
const dateStr = String(item.jqsj || '').slice(0, 10)
const slotKey = this.courseClassToSlotKey(item.courseClass)
if (dateStr && slotKey) off[dateStr + '#' + slotKey] = item.jqmc || '校历不可排课'
})
this.schoolOff = off
// 已排课次 -> 蓝
const lessons = {}
lsList.forEach(item => {
const dateStr = String(item.rq || '').slice(0, 10)
const slotKey = this.jcToSlotKey(item.jc)
if (!dateStr || !slotKey) return
const key = dateStr + '#' + slotKey
if (!lessons[key]) lessons[key] = []
lessons[key].push(item)
})
this.lessons = lessons
this.renderVenueEvents(evList)
})
},
renderVenueEvents(list) {
// 同格(日期+节次段)两行单节次记录归并为一个事件
const merged = {}
list.forEach(row => {
const dateStr = String(row.rq || '').slice(0, 10)
const slotKey = this.jcToSlotKey(row.jc)
if (!dateStr || !slotKey) return
const pos = this.locateDateSlot(dateStr, slotKey)
if (!pos) return
const colIndex = this.colIndexOf(pos.dayIndex, pos.slotKey)
const ck = colIndex >= 0 ? this.cellKey(pos.wIdx, colIndex) : null
const storeKey = ck || this.cellStableKey(pos.wIdx, pos.dayIndex, pos.slotKey)
const bucket = ck ? merged : this.hiddenEvents
const prev = bucket[storeKey]
if (prev) {
if (row.id) prev.ids.push(row.id)
if (!prev.name && row.mc) prev.name = row.mc
if (row.kpk === false || row.kpk === 0) prev.schedulable = false
if (!prev.remark && row.bz) prev.remark = row.bz
} else {
bucket[storeKey] = {
ids: row.id ? [row.id] : [],
name: row.mc || '',
schedulable: !(row.kpk === false || row.kpk === 0),
remark: row.bz || ''
}
}
})
this.events = merged
},
// 日期 + 节次格 -> 网格位置
locateDateSlot(dateStr, slotKey) {
if (!this.calendarStart || !dateStr) return null
const d = new Date(dateStr.replace(/-/g, '/'))
if (isNaN(d.getTime())) return null
const offset = Math.round((d - this.calendarStart) / (24 * 3600 * 1000))
if (offset < 0) return null
return { wIdx: Math.floor(offset / 7), dayIndex: offset % 7, slotKey }
},
cellDateStr(wIdx, dayIndex) {
const d = this.cellDate(wIdx, dayIndex)
return d ? fmtDate(d) : ''
},
isSchoolOff(dateStr, slotKey) {
return !!this.schoolOff[dateStr + '#' + slotKey]
},
hasLessonAt(dateStr, slotKey) {
return !!(this.lessons[dateStr + '#' + slotKey] || []).length
},
getLessons(wIdx, colIndex) {
const col = this.visibleColumns.find(c => c.colIndex === colIndex)
if (!col) return null
return this.lessons[this.cellDateStr(wIdx, col.dayIndex) + '#' + col.slotKey] || null
},
lessonTextOf(wIdx, colIndex) {
const list = this.getLessons(wIdx, colIndex) || []
const names = list.map(l => l.kcmc).filter(Boolean)
return names.length ? names.join('') : '已排课'
},
cellTitle(wIdx, col) {
if (this.mode !== 'venue') return ''
const dateStr = this.cellDateStr(wIdx, col.dayIndex)
const parts = []
const offName = this.schoolOff[dateStr + '#' + col.slotKey]
if (offName) parts.push('校历:' + offName + '(不可排课)')
const ls = this.lessons[dateStr + '#' + col.slotKey] || []
ls.forEach(l => {
parts.push('课次:' + (l.kcmc || '-') +
(l.jyxm ? ' / 教员:' + l.jyxm : '') +
(l.xydmc ? ' / 班次:' + l.xydmc : ''))
})
const ev = this.getEvent(wIdx, col.colIndex)
if (ev && (ev.name || ev.remark)) {
parts.push('场地事件:' + (ev.name || '-') + (ev.remark ? '' + ev.remark + '' : ''))
}
return parts.join('\n')
},
// 场地历可编辑格过滤:剔除校历不可排、已排课次、学期范围外的格
filterVenueEditable(keys) {
const ok = []
let skipped = 0
keys.forEach(key => {
const pos = this.parseCellKey(key)
if (!pos) { skipped++; return }
const dateStr = this.cellDateStr(pos.wIdx, pos.dayIndex)
if (this.isOutOfRange(pos.wIdx, pos.dayIndex)
|| this.isSchoolOff(dateStr, pos.slotKey)
|| this.hasLessonAt(dateStr, pos.slotKey)) {
skipped++
return
}
ok.push({ key, pos, dateStr })
})
return { ok, skipped }
},
periodsOfSlot(slotKey) {
const range = parseSlotRange(this.courseClassOfSlot(slotKey))
if (!range) return []
const list = []
for (let n = range[0]; n <= range[1]; n++) list.push(n)
return list
},
/* ---------- 规律生成 ---------- */
openRuleDialog() {
if (!this.toolbar.eventName.trim()) {
this.$message.warning('请先在上方工具栏填写事件名称')
return
}
this.ruleDialog.weekEnd = this.totalWeeks || 1
this.ruleDialog.visible = true
},
applyRuleGenerate() {
const name = this.toolbar.eventName.trim()
if (!name) {
this.$message.warning('请先填写事件名称')
return
}
const { cells } = this.ruleTargets
if (!cells.length) {
this.$message.warning('所选条件下没有可写入的时间格')
return
}
this.ruleDialog.submitting = true
this.saveVenueCells(cells, name).then(ok => {
if (ok) this.ruleDialog.visible = false
}).finally(() => {
this.ruleDialog.submitting = false
})
},
// 把 [{dateStr, slotKey}] 展开为单节次行并批量保存
saveVenueCells(cells, name) {
const rows = []
cells.forEach(c => {
this.periodsOfSlot(c.slotKey).forEach(jc => {
rows.push({
jsbh: this.jsbh,
rq: c.dateStr + ' 00:00:00',
jc: jc,
kpk: !!this.toolbar.schedulable,
mc: name,
bz: this.toolbar.remark || null
})
})
})
if (!rows.length) return Promise.resolve(false)
return batchSaveVenueCalendar(rows).then(res => {
this.$message.success((res && res.message) || '已保存 ' + rows.length + ' 条场地历')
this.reloadEvents()
return true
}).catch(() => false)
},
// 根据假期记录反推时间格位置(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()
const direct = this.slotDefs.find(x => x.courseClass === s)
if (direct) return direct.key
// 兼容 "910"/"1112"/"12" 等紧凑写法:按单节次归并到所属段
if (/^\d+$/.test(s) && s.length > 1) {
const first = this.jcToSlotKey(Number(s.slice(0, s.length - 1)))
if (first) return first
}
return this.jcToSlotKey(s)
},
// 获取日期所在周的周一(周一为一周起点)
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.mode === 'venue') && this.isOutOfRange(wIdx, col.dayIndex)) {
classes.push('is-out-of-range')
}
const ev = this.getEvent(wIdx, col.colIndex)
if (this.mode === 'venue') {
const dateStr = this.cellDateStr(wIdx, col.dayIndex)
// 校历不可排(节假日)置灰只读;已排课次蓝色只读
if (this.isSchoolOff(dateStr, col.slotKey)) classes.push('is-school-off')
if (this.hasLessonAt(dateStr, col.slotKey)) classes.push('has-lesson')
// 场地事件:不可排标红,仍可排的纯备注标黄
if (ev) classes.push(ev.schedulable ? 'has-note' : 'no-schedule')
} else 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]
// 场地历:过滤不可编辑格(校历不可排/已排课次/范围外),按单节次展开批量保存
if (this.mode === 'venue') {
const { ok, skipped } = this.filterVenueEditable(keys)
if (skipped > 0) {
this.$message.warning(`已跳过 ${skipped} 个不可编辑格(校历不可排/已排课次/范围外)`)
}
if (!ok.length) return
this.saveVenueCells(ok.map(t => ({ dateStr: t.dateStr, slotKey: t.pos.slotKey })), name)
return
}
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: !!this.toolbar.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]
// 场地历:收集事件行 ID 走批量删除
if (this.mode === 'venue') {
const ids = []
keys.forEach(key => {
const ev = this.events[key]
if (ev && ev.ids) ids.push(...ev.ids)
})
if (!ids.length) {
this.$message.warning('所选时间格没有场地事件')
return
}
batchDeleteVenueCalendar(ids).then(res => {
this.$message.success((res && res.message) || '已删除场地事件')
keys.forEach(key => this.$delete(this.events, key))
this.selectedKeys = []
this.reloadEvents()
}).catch(() => {})
return
}
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.autoSchedule = !!target.autoSchedule
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 (this.mode === 'venue') {
const ls = this.getLessons(wIdx, col.colIndex)
if (ls && ls.length && !(ev && ev.name)) {
const lines = ls.map(l => '课程:' + (l.kcmc || '-') +
(l.jyxm ? ' 教员:' + l.jyxm : '') +
(l.xydmc ? ' 班次:' + l.xydmc : ''))
this.$alert(lines.join('<br/>'), '已排课次', { dangerouslyUseHTMLString: true })
return
}
}
if (ev && ev.name) {
this.toolbar.eventName = ev.name
this.toolbar.bold = !!ev.bold
this.toolbar.schedulable = !!ev.schedulable
this.toolbar.autoSchedule = !!ev.autoSchedule
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 if (this.mode === 'venue') this.loadVenueEvents()
else this.loadXqxlbEvents()
this.$message.info('已刷新')
},
reloadEvents() {
this.events = {}
this.hiddenEvents = {}
if (this.mode === 'class') this.loadClassEvents()
else if (this.mode === 'venue') this.loadVenueEvents()
else this.loadXqxlbEvents()
},
savePayload(payload) {
return this.mode === 'class' ? updateClassCalendarEvent(payload) : updateXqxlb(payload)
},
/* ---------- 后端持久化(xqxlb 接口) ---------- */
// 批量保存选中格事件,返回各格子保存成功/失败的个数
persistEvents(keys) {
// 班历走批量接口:一次拖选只发一个请求,整批单事务提交
if (this.mode === 'class') {
const cells = keys
.filter(key => this.events[key])
.map(key => this.buildXqxlbPayload(key, this.events[key]))
.filter(Boolean)
if (!cells.length) return Promise.resolve({ ok: 0, fail: 0 })
return batchSaveClassCalendarEvent(this.xydxqbh, cells).then(res => {
this.$message.success((res && res.message) || '已设定 ' + cells.length + ' 个时间格')
this.reloadEvents()
return { ok: cells.length, fail: 0 }
}).catch(() => {
this.$message.error('班历批量保存失败')
return { ok: 0, fail: cells.length }
})
}
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 }
})
},
// 批量删除选中格事件:班历走 batchSave 的 action='clear' 恢复默认,校历仍走 update 置空名称
persistDeleteEvents(keys) {
if (this.mode === 'class') {
const cells = keys
.filter(key => this.events[key])
.map(key => {
const payload = this.buildXqxlbPayload(key, this.events[key])
if (!payload) return null
payload.jqmc = ''
payload.action = 'clear'
return payload
})
.filter(Boolean)
if (!cells.length) return Promise.resolve({ ok: 0, fail: 0 })
return batchSaveClassCalendarEvent(this.xydxqbh, cells).then(() => {
this.$message.success('已删除 ' + cells.length + ' 个时间格事件')
this.reloadEvents()
return { ok: cells.length, fail: 0 }
}).catch(() => {
this.$message.error('班历批量删除失败')
return { ok: 0, fail: cells.length }
})
}
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: this.courseClassOfSlot(pos.slotKey),
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 === 'school') {
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-venue-banner {
padding: 6px 14px;
font-size: 12px;
color: #e6a23c;
background: #fdf6ec;
border-bottom: 1px solid #ebeef5;
}
/* 时间编排区 */
.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; }
// 场地历:仍可排的纯备注事件标黄
&.has-note { background: #fdf6ec; }
// 场地历:已排课次蓝格(只读)
&.has-lesson {
background: #e8f1fd;
cursor: default;
.sce-lesson-name {
display: block;
font-size: 10px;
color: #409eff;
line-height: 1;
padding: 2px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// 场地历:校历不可排格置灰只读
&.is-school-off {
background: #f0f0f0;
cursor: not-allowed;
.sce-date-num { color: #c0c4cc; }
}
// 班历:班次日期范围外的格子不可排
&.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; }
.sce-legend {
float: right;
.lg {
display: inline-block;
width: 10px;
height: 10px;
margin: 0 4px 0 14px;
border: 1px solid #dcdfe6;
vertical-align: middle;
}
.lg-red { background: #fde2e2; }
.lg-yellow { background: #fdf6ec; }
.lg-blue { background: #e8f1fd; }
.lg-gray { background: #f0f0f0; }
}
}
}
.rule-week { width: 110px; }
.rule-sep { margin: 0 8px; color: #606266; }
.rule-unit { margin-left: 8px; color: #606266; }
.rule-hint { font-size: 12px; color: #909399; }
</style>