forked from liweijie/education
f3ae9fb2fd
2、配当、任务书完善、排课窗完善; 3、任务书批量发布、删除等; 4、班历同步校历
1492 lines
55 KiB
Vue
1492 lines
55 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' ? '请输入班历事件名称' : (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 -> show78,9-10 -> showNight,11+ -> 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>
|