前端文件

This commit is contained in:
2026-09-10 17:42:46 +08:00
parent 255a0e56a5
commit 345f425a1d
361 changed files with 73359 additions and 0 deletions
@@ -0,0 +1,860 @@
<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="请输入校历事件名称" />
<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'
// 节次定义: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: '' }
},
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 (val) this.loadSemester()
}
},
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: {
/* ---------- 数据加载 ---------- */
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('学期详情获取失败,请确认学期数据')
})
},
// 从后端拉取本学期校历事件,渲染到对应时间格(年度由 6 位学期代号截取前 4 位)
// XQXLB 的 nd 存的是「年份」(如 2026),春/夏/秋三学期共用同一批年度记录;
// 后端 updateById 只能改已存在的 bh,所以每个格子必须有预生成的记录才能存。
// 若本学期日期区间 [kxrq, jsrq] 内还没有任何占位记录(首次进入该学期),
// 调用 /xqxlb/add 只按本学期 kxrq/jsrq 预生成这一张网格,再重新拉取,
// 这样后续 /xqxlb/update 的 updateById 才能命中每一格。(区间有覆盖则跳过,幂等)
loadXqxlbEvents() {
const fetchList = () => listXqxlb({ nd: this.xqxlbNd }).then(res => {
const data = res.data
return Array.isArray(data) ? data : (data && data.records) || []
})
const render = (list) => {
// 后端 add() 的按「月日」去重对已存的全量日期不生效,同一年度多次打开/刷新会
// 积累同一格的重复占位记录(各带不同 bh)。updateById 只改其中一行,其余空记录
// 会在重新拉取时把刚保存的事件名覆盖掉。故这里按「周-星期-节次」先去重:
// 同一格保留有事件名(jqmc非空)的一条;都没有则保留最后一条兜底(保证有 bh)。
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,
mainCourse: !!item.zdpk,
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 {
// 该节次列当前隐藏:暂存(含 bh),待列显示时由 visibleColumns 监听恢复
this.hiddenEvents[this.cellStableKey(pos.wIdx, pos.dayIndex, pos.slotKey)] = ev
}
})
// 合并到现有格子(后端数据覆盖已有状态)
this.events = Object.assign({}, this.events, loaded)
}
fetchList().then(list => {
const hasCoverage = list.some(item => this.isInRange(item.jqsj))
if (!hasCoverage && this.startDate && this.endDate) {
// 本学期区间尚无占位记录:仅用本期 kxrq/jsrq 预生成整张网格
const startStr = fmtDate(this.startDate)
const endStr = fmtDate(this.endDate)
return addXqxlb(startStr, endStr).then(() => fetchList())
}
return list
}).then(render).catch(() => {
this.$message.warning('校历事件加载失败')
})
},
// 判断某条记录(jqsj 日期)是否落在本学期 [startDate, endDate] 区间内
isInRange(jqsj) {
if (!this.startDate || !this.endDate || !jqsj) return false
const d = new Date(String(jqsj).slice(0, 10).replace(/-/g, '/'))
if (isNaN(d.getTime())) return false
return d >= this.startDate && d <= this.endDate
},
// 根据假期记录反推时间格位置(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')
const ev = this.getEvent(wIdx, col.colIndex)
// 可排课的全部白色;不可排课且事件名非空才标红
if (ev && ev.name && !ev.schedulable) {
classes.push('no-schedule')
}
return classes
},
/* ---------- 点击 / 拖拽多选 ---------- */
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
}
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,
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 = {}
this.loadXqxlbEvents()
this.$message.info('已刷新')
},
/* ---------- 后端持久化(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 updateXqxlb(payload)
.then(() => {
console.warn('[设定] key=', key, 'bh来源=', this.events[key] && this.events[key].bh ? '已有' : '生成', 'bh=', payload.bh, 'nd=', payload.nd, 'name=', payload.jqmc)
return 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.loadXqxlbEvents()
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 updateXqxlb(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.loadXqxlbEvents()
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.mainCourse,
courseClass: SLOT_COURSE_MAP[pos.slotKey] || null
}
},
// 时间格 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')
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-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>
@@ -0,0 +1,517 @@
<template>
<div class="teach-calendar app-container">
<!-- 顶部标题:学期名称-校历 -->
<div class="cal-titlebar">
<span class="cal-title">{{ titleText }}</span>
</div>
<!-- 显示控制栏 -->
<div class="cal-panel">
<el-checkbox v-model="showDate" class="cal-cb">显示日期</el-checkbox>
<div class="cal-panel-right">
<el-button size="mini" type="primary" icon="el-icon-download" @click="exportExcel">另存为Excel</el-button>
</div>
</div>
<!-- 学期校历表格 -->
<div class="cal-grid-wrap">
<table class="cal-grid">
<thead>
<tr>
<th class="cal-corner cal-th-weekno" :rowspan="2">周次</th>
<th class="cal-corner cal-th-weekrange" :rowspan="2">日期段</th>
<th v-for="day in weekDayHeaders" :key="'h1-' + day.dayIndex" :colspan="day.colCount" class="cal-th-day">
{{ day.label }}
</th>
</tr>
<tr>
<th v-for="col in visibleColumns" :key="'h2-' + col.colIndex" class="cal-th-slot">
{{ col.slotLabel }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(week, wIdx) in weeks" :key="'w-' + wIdx">
<td class="cal-week-cell cal-weekno-cell">{{ wIdx + 1 }}</td>
<td class="cal-week-cell cal-weekrange-cell">{{ week.rangeText }}</td>
<td
v-for="col in visibleColumns"
:key="'c-' + wIdx + '-' + col.colIndex"
class="cal-cell"
:class="cellClass(wIdx, col)"
>
<template v-if="getEvent(wIdx, col.colIndex) && getEvent(wIdx, col.colIndex).name">
<span class="cal-event-name">{{ getEvent(wIdx, col.colIndex).name }}</span>
</template>
<span v-else-if="showDate" class="cal-date-num">{{ dateNumberOf(wIdx, col.dayIndex) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 底部状态栏 -->
<div class="cal-status">
<span v-if="nd">从 {{ dateRangeText }} ,共 {{ totalWeeks }} 周</span>
<span v-else>请选择学期</span>
</div>
</div>
</template>
<script>
import { saveAs } from 'file-saver'
import { listSemester, getSemester } from '@/api/teachBusiness/semester'
import { listXqxlb } from '@/api/teachBusiness/xqxlb'
// 节次定义:全部常显
const SLOT_DEFS = [
{ key: '12', label: '1-2' },
{ key: '34', label: '3-4' },
{ key: '56', label: '5-6' },
{ key: '78', label: '7-8' },
{ key: 'night', label: '晚上' },
{ key: 'late', label: '夜间' }
]
// 前端节次 key -> 后端 xqxlb.courseClass 节次范围
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())
}
export default {
name: 'SemesterCalendar',
data() {
return {
// 当前学期代号(跟随顶栏切换学期,自动更新为当前学期)
nd: '',
// 学期日期范围
startDate: null,
endDate: null,
// 日历渲染起点(学期当周周一)
calendarStart: null,
totalWeeks: 0,
dateRangeText: '',
weeks: [],
// 显示控制:默认不勾选显示日期;节次全部展示
showDate: false,
// 事件存储:key = cellKey -> event
events: {},
loading: false
}
},
computed: {
// 页面标题:学期名称-校历
titleText() {
if (!this.nd) return '学期校历'
return this.getSemesterName(this.nd) + '-校历'
},
// 当前可见列(全部节次常显)
visibleColumns() {
const cols = []
let colIndex = 0
WEEK_DAYS.forEach((dayLabel, dayIndex) => {
SLOT_DEFS.forEach(slot => {
cols.push({ colIndex: colIndex, dayIndex: dayIndex, slotKey: slot.key, slotLabel: slot.label, dayLabel: dayLabel })
colIndex++
})
})
return cols
},
// 表头第一行星期:每个星期合并其下全部节次列
weekDayHeaders() {
return WEEK_DAYS.map((label, dayIndex) => ({
dayIndex: dayIndex,
label: label,
colCount: SLOT_DEFS.length
}))
}
},
created() {
// 初始加载当前学期
this.loadCurrentSemester()
// 顶栏切换学期后自动刷新为当前学期的校历
this.$root.$on('semester-current-changed', this.loadCurrentSemester)
},
beforeDestroy() {
this.$root.$off('semester-current-changed', this.loadCurrentSemester)
},
methods: {
/* ---------- 加载当前学期 ---------- */
loadCurrentSemester() {
this.loading = true
listSemester({ pageNum: 1, pageSize: 100 }).then(response => {
const data = response.data || {}
const list = data.records || []
// 跟随顶栏当前学期(dqxq=true),无则取第一条
const current = list.find(i => i.dqxq) || list[0]
this.loading = false
if (current) {
if (String(this.nd) !== String(current.nd)) {
this.nd = current.nd
this.loadSemester()
}
}
}).catch(() => {
this.loading = false
})
},
/* ---------- 数据加载 ---------- */
loadSemester() {
if (!this.nd) return
this.startDate = null
this.endDate = null
this.calendarStart = null
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.buildWeeks()
}
this.loadXqxlbEvents()
})
.catch(() => {
this.$message.warning('学期详情获取失败,请确认学期数据')
})
},
// 从后端拉取校历事件,渲染到对应时间格
// xqxlb.nd 后端已统一为 6 位学期代号(如 202701),与 this.nd 一致,一次查询即可
loadXqxlbEvents() {
if (!this.calendarStart || !this.nd) return
listXqxlb({ nd: Number(String(this.nd).slice(0, 6)) || Number(this.nd) }).then(res => {
const data = res.data
const list = Array.isArray(data) ? data : (data && data.records) || []
const loaded = {}
// 同一格可能有多条(后端 add() 去重失效累积的重复占位)。
// 按格子去重:同一格优先保留「有事件名(jqmc非空)」的那条,避免空占位把事件盖成空白。
const unique = {}
list.forEach(item => {
const pos = this.locateXqxlb(item)
if (!pos) return
const prev = unique[pos.key]
const has = !!item.jqmc
if (!prev || (has && !prev.has)) {
unique[pos.key] = { has: has, item: item }
}
})
Object.keys(unique).forEach(key => {
const { item } = unique[key]
loaded[key] = {
bh: item.bh,
name: item.jqmc || '',
schedulable: !!item.kpk,
mainCourse: !!item.zdpk,
remarkShow: !!item.bzxs,
remark: item.bz || ''
}
})
this.events = loaded
}).catch(() => {
this.$message.warning('校历事件加载失败')
})
},
// 根据假期记录反推时间格 key(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
const colIndex = this.colIndexOf(dayIndex, slotKey)
if (colIndex < 0) return null
return { key: this.cellKey(wIdx, colIndex) }
},
// 后端 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
},
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 = []
const ev = this.getEvent(wIdx, col.colIndex)
// 可排课的全部白色;不可排课且事件名非空才标红
if (ev && ev.name && !ev.schedulable) {
classes.push('no-schedule')
}
return classes
},
/** 学期代号 -> 学期名称(如 202601 -> 2026年春季学期) */
getSemesterName(nd) {
if (!nd) return ''
const s = String(nd)
const xn = s.slice(0, 4)
const xq = s.slice(4)
const map = { '01': '春季学期', '02': '夏季学期', '03': '秋季学期' }
return xn + '年' + (map[xq] || '第' + xq + '学期')
},
/* ---------- 另存为Excel ---------- */
exportExcel() {
if (!this.weeks.length) {
this.$message.warning('暂无校历数据可导出')
return
}
// 按星期分组节次列,用于表头合并
const dayCols = []
this.visibleColumns.forEach(col => {
if (!dayCols[col.dayIndex]) dayCols[col.dayIndex] = []
dayCols[col.dayIndex].push(col)
})
let html = '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">'
html += '<head><meta charset="utf-8"></head><body>'
html += '<table border="1" cellspacing="0" cellpadding="4" style="border-collapse:collapse;">'
// 标题行
html += '<tr><td colspan="' + (this.visibleColumns.length + 2) + '" style="font-size:15px;font-weight:bold;text-align:center;">' + this.titleText + '</td></tr>'
// 表头第一行:星期(合并同类天)
html += '<tr><th rowspan="2">周次</th><th rowspan="2">日期段</th>'
WEEK_DAYS.forEach((dayLabel, dayIndex) => {
const cols = dayCols[dayIndex] || []
if (cols.length) html += '<th colspan="' + cols.length + '">' + dayLabel + '</th>'
})
html += '</tr>'
// 表头第二行:节次
html += '<tr>'
this.visibleColumns.forEach(col => {
html += '<th>' + col.slotLabel + '</th>'
})
html += '</tr>'
// 数据行
this.weeks.forEach((week, wIdx) => {
html += '<tr>'
html += '<td>' + (wIdx + 1) + '</td>'
html += '<td>' + week.rangeText + '</td>'
this.visibleColumns.forEach(col => {
const ev = this.getEvent(wIdx, col.colIndex)
if (ev && ev.name) {
const remark = ev.remark && ev.remarkShow ? '(' + ev.remark + ')' : ''
html += '<td>' + ev.name + remark + '</td>'
} else if (this.showDate) {
html += '<td>' + this.dateNumberOf(wIdx, col.dayIndex) + '</td>'
} else {
html += '<td></td>'
}
})
html += '</tr>'
})
html += '</table></body></html>'
const blob = new Blob(['\ufeff' + html], { type: 'application/vnd.ms-excel;charset=utf-8' })
saveAs(blob, (this.titleText || '学期校历') + '.xls')
this.$message.success('已导出Excel')
}
}
}
</script>
<style scoped lang="scss">
.teach-calendar {
height: 100%;
display: flex;
flex-direction: column;
padding: 12px;
box-sizing: border-box;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
/* 顶部标题栏 */
.cal-titlebar {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
background: var(--edu-green-primary, #00875a);
color: #fff;
border-radius: 4px 4px 0 0;
.cal-title {
font-size: 15px;
font-weight: 600;
}
}
/* 显示控制栏 */
.cal-panel {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 8px 14px;
border-bottom: 1px solid #ebeef5;
background: #fafafa;
.cal-cb { margin-right: 14px; }
.cal-panel-right { margin-left: auto; }
}
/* 表格区域 */
.cal-grid-wrap {
flex: 1;
overflow: auto;
padding: 8px 10px;
background: #fff;
}
.cal-grid {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
font-size: 12px;
th, td {
border: 1px solid #e4e7ed;
text-align: center;
padding: 0;
}
.cal-corner {
background: #f5f7fa;
font-weight: 600;
color: #303133;
vertical-align: middle;
}
.cal-th-weekno { width: 52px; }
.cal-th-weekrange { width: 110px; }
.cal-th-day {
background: var(--edu-green-primary, #00875a);
color: #fff;
font-weight: 600;
height: 26px;
}
.cal-th-slot {
background: #ecf5f0;
color: #00875a;
height: 22px;
font-weight: 500;
}
.cal-week-cell {
background: #f5f7fa;
vertical-align: middle;
white-space: nowrap;
}
.cal-weekno-cell {
font-weight: 600;
color: #303133;
font-size: 13px;
}
.cal-weekrange-cell {
font-size: 11px;
color: #909399;
}
.cal-cell {
height: 30px;
position: relative;
background: #fff;
.cal-event-name {
display: block;
line-height: 1;
padding: 2px 0;
}
.cal-date-num {
display: block;
font-size: 10px;
color: #c0c4cc;
line-height: 1;
}
// 可排课白色,不可排课且有事件标红
&.no-schedule { background: #fde2e2; }
}
}
/* 底部状态栏 */
.cal-status {
padding: 6px 14px;
font-size: 12px;
color: #909399;
border-top: 1px solid #ebeef5;
background: #fafafa;
}
}
</style>
@@ -0,0 +1,64 @@
<template>
<div class="teach-calendar app-container">
<school-calendar-editor
v-if="nd"
:key="nd"
:nd="nd"
:semester-name="semesterName"
@close="handleClose"
/>
<div v-else class="calendar-empty">未指定学期,请从学期管理页面进入。</div>
</div>
</template>
<script>
import SchoolCalendarEditor from '@/views/teachBusiness/calendar/components/SchoolCalendarEditor'
export default {
name: 'SemesterCalendarEdit',
components: { SchoolCalendarEditor },
data() {
return {
nd: '',
semesterName: ''
}
},
created() {
this.nd = this.$route.query.nd || ''
this.semesterName = this.$route.query.name || ''
},
watch: {
// 编辑校历页被 keep-alive 缓存复用:同路径下 query.nd 变化时不会重新走 created(),
// 需在此同步到 nd,并通过 :key="nd" 让编辑器随学期切换全新挂载,读取正确学期数据。
'$route.query.nd'(val) {
if (val && String(val) !== String(this.nd)) {
this.nd = val
this.semesterName = this.$route.query.name || ''
}
}
},
methods: {
handleClose() {
this.$router.replace({ path: '/teachBusiness/semester' })
}
}
}
</script>
<style scoped>
.teach-calendar {
height: 100%;
display: flex;
flex-direction: column;
padding: 12px;
box-sizing: border-box;
}
.calendar-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 14px;
}
</style>
@@ -0,0 +1,647 @@
<template>
<div class="app-container teach-semester">
<!-- 工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新添</el-button>
</el-col>
<el-col :span="1.8">
<el-button type="success" plain icon="el-icon-date" size="mini"
@click="handleEditCalendar">
编辑校历
</el-button>
</el-col>
</el-row>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="semesterList" border :height="tableHeight" highlight-current-row
@current-change="handleCurrentChange">
<el-table-column label="学期名称" align="center" width="250">
<template slot-scope="scope">
<span>{{ getSemesterName(scope.row.nd) }}</span>
</template>
</el-table-column>
<el-table-column label="当前" align="center" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.dqxq" type="success" size="mini">当前</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="开学日期" align="center" prop="kxrq" width="160">
<template slot-scope="scope">
<span>{{ formatDate(scope.row.kxrq) }}</span>
</template>
</el-table-column>
<el-table-column label="结束日期" align="center" prop="jsrq" width="160">
<template slot-scope="scope">
<span>{{ formatDate(scope.row.jsrq) }}</span>
</template>
</el-table-column>
<el-table-column label="周数" align="center" prop="sdzs" width="55" />
<el-table-column label="调课不审批" align="center" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.tkbsp ? 'primary' : 'info'" size="mini">{{ scope.row.tkbsp ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="禁止调课" align="center" width="80">
<template slot-scope="scope">
<el-tag :type="scope.row.jztk ? 'danger' : 'info'" size="mini">{{ scope.row.jztk ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="终结成绩定及格" align="center" width="130">
<template slot-scope="scope">
<el-tag :type="scope.row.zjcjdjg ? 'primary' : 'info'" size="mini">{{ scope.row.zjcjdjg ? '是' : '否'
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="课时系数方案" align="center" width="160">
<template slot-scope="scope">
<span>{{ getCoefficientLabel(scope.row.ksxsfabh) }}</span>
</template>
</el-table-column>
<el-table-column label="锁定时长" align="center" width="100">
<template slot-scope="scope">
<span>{{ scope.row.sdjldw || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" class-name="table-action-column">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleUpdate(scope.row)">编辑</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" class="text-danger"
@click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
@pagination="getList" />
<!-- 学期信息弹窗 -->
<el-dialog :title="title" :visible.sync="open" width="900px" append-to-body class="semester-dialog">
<el-alert v-if="isSemesterNameDuplicated()" class="semester-dup-alert" type="warning" show-icon
title="该学期已存在,不能重复添加" :closable="false" />
<el-form ref="form" class="semester-dialog-form" :model="form" :rules="rules" label-width="96px"
label-position="right">
<el-row :gutter="20" class="semester-dialog-body">
<!-- 左侧表单区 -->
<el-col :span="15" class="semester-form-col">
<el-form-item label="学期名称">
<el-input :value="getSemesterName(buildNd(form.xn, form.xq))" readonly placeholder="请在下方选择年份和学期类型"
prefix-icon="el-icon-office-building" />
</el-form-item>
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="年份" prop="xn">
<el-select v-model="form.xn" placeholder="请选择年份" :disabled="!isAdd" style="width: 100%"
@change="checkSemesterNameUnique">
<el-option v-for="item in yearOptions" :key="item" :label="item + '年'" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学期类型" prop="xq">
<el-select v-model="form.xq" placeholder="请选择学期类型" :disabled="!isAdd" style="width: 100%"
@change="checkSemesterNameUnique">
<el-option v-for="item in semesterOptions" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="开学日期" prop="kxrq">
<el-date-picker v-model="form.kxrq" type="date" placeholder="开学日期" value-format="yyyy-MM-dd"
style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束日期" prop="jsrq">
<el-date-picker v-model="form.jsrq" type="date" placeholder="结束日期" value-format="yyyy-MM-dd"
style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="学期周数" prop="sdzs">
<el-input-number v-model="form.sdzs" :min="1" :max="60" controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课时系数方案" prop="ksxsfabh">
<el-select v-model="form.ksxsfabh" placeholder="请选择" clearable style="width: 100%">
<el-option v-for="item in coefficientOptions" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="锁定时长">
<div class="lock-group">
<el-input-number v-model="form.lockValue" :min="0" :max="99" controls-position="right"
style="width: 120px" />
<el-radio-group v-model="form.lockUnit" class="lock-unit" style="margin-left: 28px">
<el-radio label="周">周</el-radio>
<el-radio label="月">月</el-radio>
</el-radio-group>
</div>
</el-form-item>
<div class="form-tip">以当前周星期四中午12点为界限,锁定【锁定周数】周的教学实施计划,被锁定的教学实施计划不允许教员自行调整。</div>
</el-col>
<!-- 右侧业务开关区 -->
<el-col :span="9" class="semester-switch-col">
<div class="switch-panel-title">业务开关</div>
<div class="switch-panel">
<div class="switch-item">
<el-checkbox v-model="form.dqxq">当前学期</el-checkbox>
<div class="switch-tip">设定当前学期后,系统启动时会自动将该学期设置为默认学期。</div>
</div>
<div class="switch-item">
<el-checkbox v-model="form.jztk">禁止调课</el-checkbox>
<div class="switch-tip">设定【禁止调课】后,教学信息系统中就无法对课程信息进行调整。</div>
</div>
<div class="switch-item">
<el-checkbox v-model="form.tkbsp">调课不审批</el-checkbox>
<div class="switch-tip">
设定【调课不审批】后,在教学信息系统中调课时,如果只是更改教员,或更改授课地点,调课申请会立即生效。而对于改变授课地点,调课申请会立即生效。而对于改变授课时间、增加或减少授课教员的调课申请依然不受该设置影响,依然需要逐级审批后才能生效。
</div>
</div>
<div class="switch-item">
<el-checkbox v-model="form.zjcjdjg">终结成绩定及格</el-checkbox>
<div class="switch-tip">设定【终结成绩定及格】后,学员终结性成绩不及格的,最终成绩即为不及格。</div>
</div>
<div class="switch-item">
<el-checkbox v-model="form.jwldshtk">教务机关审核教学计划调整</el-checkbox>
<div class="switch-tip">勾选:教务机关需要从行政入口对每个教学计划调整申请进行审批,同时再由管理员身份教务参谋进行最后的把关;不勾选:仅由管理员身份教务参谋进行最后的把关。</div>
</div>
</div>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listSemester, getSemester, addSemester, updateSemester, delSemester } from "@/api/teachBusiness/semester"
export default {
name: "Semester",
data() {
return {
// 遮罩层
loading: true,
// 表格高度
tableHeight: window.innerHeight - 280,
// 总条数
total: 0,
// 学期列表数据
semesterList: [],
// 当前点击选中的学期
currentSemester: null,
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 是否新增
isAdd: true,
// 学年选项(当前年份 ± 10 年)
yearOptions: [],
// 学期选项:01 春季、02 夏季、03 秋季
semesterOptions: [
{ value: '01', label: '春季学期' },
{ value: '02', label: '夏季学期' },
{ value: '03', label: '秋季学期' }
],
// 课时系数方案选项(引用基础数据表,暂以静态占位)
coefficientOptions: [
{ value: '1', label: '标准系数方案' },
{ value: '2', label: '综合系数方案' },
{ value: '3', label: '实训系数方案' }
],
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10
},
// 表单参数
form: {},
// 表单校验
rules: {
xn: [
{ required: true, message: "请选择年份", trigger: "change" }
],
xq: [
{ required: true, message: "请选择学期类型", trigger: "change" }
],
kxrq: [
{ required: true, message: "请选择开学日期", trigger: "change" }
],
jsrq: [
{ required: true, message: "请选择结束日期", trigger: "change" }
]
}
}
},
created() {
this.initYearOptions()
this.getList()
// 顶栏切换当前学期后,自动刷新本页数据
this.$root.$on('semester-current-changed', this.handleCurrentChanged)
},
beforeDestroy() {
this.$root.$off('semester-current-changed', this.handleCurrentChanged)
},
watch: {
'form.kxrq': 'calculateWeeks',
'form.jsrq': 'calculateWeeks',
// 新增时选择学期类型后,自动带出开学/结束日期
'form.xq'(val) {
if (this.isAdd) this.autoFillDatesForXq()
},
'form.xn'(val) {
if (this.isAdd) this.autoFillDatesForXq()
}
},
methods: {
/** 顶栏切换当前学期后自动刷新 */
handleCurrentChanged() {
this.getList()
},
/** 初始化学年选项(新增时只能从当前年份往后选择) */
initYearOptions() {
const current = new Date().getFullYear()
const list = []
for (let i = current; i <= current + 10; i++) {
list.push(i)
}
this.yearOptions = list
},
/** 学年+学期 -> 学期代号(如 2026 + 01 = 202601) */
buildNd(xn, xq) {
if (xn === undefined || xn === null || xn === '' || !xq) return ''
return String(xn) + String(xq)
},
/** 根据学年+学期类型自动带出开学/结束日期(春季01:2-5月,夏季02:6-8月,秋季03:9-12月) */
autoFillDatesForXq() {
const xn = this.form.xn
const xq = this.form.xq
if (xn === undefined || xn === null || xn === '') return
const year = Number(xn)
const pad = m => `${year}-${String(m).padStart(2, '0')}`
const rules = {
'01': { start: pad(2) + '-01', end: pad(5) + '-31' },
'02': { start: pad(6) + '-01', end: pad(8) + '-31' },
'03': { start: pad(9) + '-01', end: pad(12) + '-31' }
}
const rule = rules[xq]
if (!rule) return
this.form.kxrq = rule.start
this.form.jsrq = rule.end
},
/** 学期代号 -> 学期名称(如 202601 -> 2026年春季学期) */
getSemesterName(nd) {
if (!nd) return ''
const s = String(nd)
const xn = s.slice(0, 4)
const xq = s.slice(4)
const map = { '01': '春季学期', '02': '夏季学期', '03': '秋季学期' }
return xn + '年' + (map[xq] || '第' + xq + '学期')
},
/** 学期代号 -> {xn, xq} */
parseNd(nd) {
if (!nd) return { xn: undefined, xq: undefined }
const s = String(nd)
return { xn: s.slice(0, 4), xq: s.slice(4) }
},
/** 新增时校验学期名称(年份+学期类型拼接)是否已存在于表格 */
isSemesterNameDuplicated() {
if (!this.isAdd) return false
const nd = this.buildNd(this.form.xn, this.form.xq)
if (!nd) return false
return this.semesterList.some(item => String(item.nd) === String(nd))
},
/** 年份/学期类型切换时实时刷新顶部重复提示 */
checkSemesterNameUnique() {
this.$forceUpdate()
},
/** 课时系数方案编号 -> 名称 */
getCoefficientLabel(value) {
if (!value && value !== 0) return '-'
const found = this.coefficientOptions.find(o => String(o.value) === String(value))
return found ? found.label : value
},
/** 格式化后端 LocalDateTime(2026-02-23T00:00:00 -> 2026-02-23) */
formatDate(value) {
if (!value) return ''
return String(value).slice(0, 10)
},
/** 根据开学日期和结束日期自动计算学期周数 */
calculateWeeks() {
const kxrq = this.form.kxrq
const jsrq = this.form.jsrq
if (!kxrq || !jsrq) return
const start = new Date(String(kxrq).slice(0, 10).replace(/-/g, '/'))
const end = new Date(String(jsrq).slice(0, 10).replace(/-/g, '/'))
if (isNaN(start.getTime()) || isNaN(end.getTime())) return
if (end < start) return
// 以开学当周周一为起点,向上取整到周
const day = start.getDay()
const monday = new Date(start)
monday.setDate(start.getDate() - (day === 0 ? 6 : day - 1))
const days = Math.round((end - monday) / (24 * 3600 * 1000)) + 1
this.$set(this.form, 'sdzs', Math.max(1, Math.ceil(days / 7)))
},
/** 查询学期列表 */
getList() {
this.loading = true
listSemester(this.queryParams).then(response => {
const data = response.data || {}
this.semesterList = data.records || []
this.total = data.total || 0
this.loading = false
}).catch(() => {
this.semesterList = []
this.total = 0
this.loading = false
})
},
/** 单击行选中变化:记录当前选中的学期,用于启用「编辑校历」 */
handleCurrentChange(currentRow) {
this.currentSemester = currentRow || null
},
/** 组装提交数据 */
buildPayload(form) {
const lockValue = form.lockValue !== undefined && form.lockValue !== null ? form.lockValue : ''
return {
delFlag: 0,
nd: this.buildNd(form.xn, form.xq),
dqxq: !!form.dqxq,
kxrq: form.kxrq ? form.kxrq + 'T00:00:00' : null,
jsrq: form.jsrq ? form.jsrq + 'T00:00:00' : null,
tkbsp: !!form.tkbsp,
jztk: !!form.jztk,
zjcjdjg: !!form.zjcjdjg,
jwldshtk: !!form.jwldshtk,
ksxsfabh: form.ksxsfabh || null,
sdzs: form.sdzs,
sdjldw: lockValue === '' ? '' : lockValue + form.lockUnit,
xh: form.xh,
jsonzd: "{'1':1}",
kstjsj: null
}
},
/** 新增按钮 */
handleAdd() {
this.reset()
this.isAdd = true
this.open = true
this.title = "学期信息 - 新增"
},
/** 编辑按钮 */
handleUpdate(row) {
const semesterId = row && row.nd ? row.nd : ''
if (!semesterId) return
this.reset()
this.isAdd = false
getSemester(semesterId).then(response => {
const data = response.data || {}
const { xn, xq } = this.parseNd(data.nd)
// 解析锁定时长(如 "2周" / "1月" / "1")
let lockValue = data.sdjldw
let lockUnit = '周'
if (lockValue) {
const s = String(lockValue)
if (s.endsWith('周')) { lockValue = s.slice(0, -1); lockUnit = '周' }
else if (s.endsWith('月')) { lockValue = s.slice(0, -1); lockUnit = '月' }
}
this.form = {
xn: xn,
xq: xq,
dqxq: !!data.dqxq,
jztk: !!data.jztk,
tkbsp: !!data.tkbsp,
zjcjdjg: !!data.zjcjdjg,
jwldshtk: !!data.jwldshtk,
ksxsfabh: data.ksxsfabh,
kxrq: this.formatDate(data.kxrq),
jsrq: this.formatDate(data.jsrq),
sdzs: data.sdzs,
lockValue: lockValue,
lockUnit: lockUnit,
xh: data.xh
}
this.open = true
this.title = "学期信息 - 编辑"
}).catch(() => { })
},
/** 编辑校历按钮:对当前选中的学期编辑校历 */
handleEditCalendar() {
const row = this.currentSemester
if (!row || !row.nd) {
this.$modal.msgWarning('请选择学期')
return
}
this.$router.push({
path: '/teachBusiness/semester/semesterCalendar',
query: { nd: row.nd, name: this.getSemesterName(row.nd) }
})
},
/** 删除按钮:单行删除 */
handleDelete(row) {
if (!row || !row.nd) return
const name = this.getSemesterName(row.nd)
this.$modal.confirm('确认删除学期【' + name + '】吗?').then(() => {
return delSemester(row.nd)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => { })
},
/** 提交表单 */
submitForm() {
if (this.isSemesterNameDuplicated()) {
this.$modal.msgWarning("该学期已存在,不能重复添加")
return
}
this.$refs["form"].validate(valid => {
if (valid) {
const payload = this.buildPayload(this.form)
const selfNd = payload.nd
// 当前学期全局只能有一个:若本次设为当前,先把其他 dqxq=true 的取消
const cancelOthers = payload.dqxq
? this.cancelOtherCurrent(selfNd)
: Promise.resolve()
cancelOthers.then(() => {
if (this.isAdd) {
return addSemester(payload)
}
return updateSemester(payload)
}).then(() => {
this.$modal.msgSuccess(this.isAdd ? "新增成功" : "修改成功")
this.open = false
this.getList()
}).catch(() => { })
}
})
},
/** 取消其它“当前学期”,保证全局唯一 */
cancelOtherCurrent(selfNd) {
return listSemester({ pageNum: 1, pageSize: 100 }).then(response => {
const data = response.data || {}
const list = data.records || []
const others = list.filter(i => i.dqxq && String(i.nd) !== String(selfNd))
if (!others.length) return Promise.resolve()
return Promise.all(others.map(o => updateSemester({ ...o, dqxq: false })))
})
},
/** 重置表单 */
reset() {
this.form = {
xn: undefined,
xq: undefined,
dqxq: false,
jztk: false,
tkbsp: false,
zjcjdjg: false,
jwldshtk: false,
ksxsfabh: undefined,
kxrq: undefined,
jsrq: undefined,
sdzs: 20,
lockValue: 1,
lockUnit: '周',
xh: 1
}
this.resetForm("form")
},
/** 取消按钮 */
cancel() {
this.open = false
this.reset()
}
}
}
</script>
<style scoped lang="scss">
.teach-semester {
.mb8 {
margin-bottom: 8px;
}
/* 弹窗内表单:防止 label 文字换行、字段拥挤换行 */
::v-deep .el-dialog__body {
padding-top: 16px;
padding-bottom: 16px;
}
/* 对话框整体两栏布局 */
.semester-dialog-body {
display: block;
}
/* 顶部学期重复提示与表单间距 */
.semester-dup-alert {
margin-bottom: 16px;
}
.semester-form-col {
padding-right: 4px;
}
.semester-switch-col {
padding-left: 24px;
border-left: 1px solid #ebeef5;
}
.semester-dialog-form {
::v-deep .el-form-item {
margin-bottom: 18px;
}
::v-deep .el-form-item__label {
white-space: nowrap;
line-height: 32px;
padding-right: 10px;
color: #606266;
}
::v-deep .el-form-item__content {
line-height: 32px;
}
.el-input,
.el-select,
.el-date-editor {
width: 100%;
}
}
.form-tip {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
white-space: normal;
padding-left: 2px;
}
.lock-group {
display: flex;
align-items: center;
}
.switch-panel-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 14px;
padding-left: 2px;
}
.switch-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.switch-item {
padding: 11px 13px;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 6px;
transition: border-color 0.2s, background 0.2s;
&:hover {
border-color: var(--edu-green-primary);
background: #fff;
}
.el-checkbox {
height: 26px;
font-weight: 600;
color: #303133;
}
.switch-tip {
font-size: 12px;
line-height: 1.6;
color: #909399;
text-align: justify;
margin: 2px 0 0 2px;
}
}
}
</style>
@@ -0,0 +1,600 @@
<template>
<div class="app-container syllabus-page">
<!-- 查询条件 -->
<el-card shadow="never" class="search-card">
<el-form :model="searchForm" label-width="100px" class="search-form" @submit.native.prevent>
<el-row :gutter="32">
<el-col :xs="24" :md="8">
<el-form-item label="专业代号">
<el-input v-model="searchForm.zydh" placeholder="请输入专业代号" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="停用">
<el-radio-group v-model="searchForm.ty">
<el-radio :label="0">否</el-radio>
<el-radio :label="1">是</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<div class="search-actions">
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
</div>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 数据列表 -->
<el-card shadow="never" class="table-card">
<div class="list-header">
<div class="list-title">教学大纲列表</div>
<div class="list-actions">
<el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button>
<el-button
type="danger"
plain
icon="el-icon-delete"
:disabled="!selection.length"
@click="handleBatchDelete"
>删除所选</el-button>
<el-button icon="el-icon-download" @click="handleDownload">下载</el-button>
</div>
</div>
<el-table v-loading="loading" :data="pagedData" border stripe highlight-current-row
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column prop="zydh" label="专业代号" width="150" align="center" />
<el-table-column prop="kbh" label="课编号" width="110" show-overflow-tooltip align="center" />
<el-table-column prop="jc" label="简称" width="100" show-overflow-tooltip align="center" />
<el-table-column prop="kcdw" label="课程定位" width="120" show-overflow-tooltip align="center" />
<el-table-column prop="mk" label="模块" width="100" show-overflow-tooltip align="center" />
<el-table-column prop="jysdh" label="教研室" width="100" show-overflow-tooltip align="center" />
<el-table-column prop="klx" label="课类型" width="80" align="center" />
<el-table-column prop="xqdc" label="学期第次" width="80" align="center" />
<el-table-column prop="xs" label="学时" width="70" align="center" />
<el-table-column prop="xf" label="学分" width="70" align="center" />
<el-table-column prop="llxs" label="理论学时" width="80" align="center" />
<el-table-column prop="sjxs" label="实践学时" width="80" align="center" />
<el-table-column prop="zks" label="周课时" width="70" align="center" />
<el-table-column prop="ksks" label="考试课时" width="80" align="center" />
<el-table-column prop="cjfz" label="成绩分制" width="90" align="center" />
<el-table-column label="不计入平均分" width="110" align="center">
<template slot-scope="scope">{{ fmtYesNo(scope.row.bjrxypjf) }}</template>
</el-table-column>
<el-table-column label="考试课时不显示" width="130" align="center">
<template slot-scope="scope">{{ fmtYesNo(scope.row.ksksbxs) }}</template>
</el-table-column>
<el-table-column label="大纲课程" width="90" align="center">
<template slot-scope="scope">{{ fmtYesNo(scope.row.dgkc) }}</template>
</el-table-column>
<el-table-column label="停用" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="Number(scope.row.ty) === 1 ? 'danger' : 'success'" size="mini">
{{ Number(scope.row.ty) === 1 ? '停用' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="启用时间" width="150" align="center">
<template slot-scope="scope">{{ fmtDateTime(scope.row.qysj) }}</template>
</el-table-column>
<el-table-column label="停用时间" width="150" align="center">
<template slot-scope="scope">{{ fmtDateTime(scope.row.tysj) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" class="danger-text-btn" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
background
layout="total, sizes, prev, pager, next"
:current-page="pageNum"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog :title="dialog.title" :visible.sync="dialog.visible" width="720px" append-to-body
:close-on-click-modal="false">
<el-form ref="syllabusForm" :model="dialog.form" :rules="rules" label-width="130px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="专业" prop="zydh">
<el-select
v-model="dialog.form.zydh"
filterable
clearable
placeholder="请选择专业"
class="w-full"
:loading="referenceDataLoading"
>
<el-option
v-for="item in majorOptions"
:key="item.zydh"
:label="item.zymc"
:value="item.zydh"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课程" prop="kbh">
<el-select
v-model="dialog.form.kbh"
filterable
clearable
placeholder="请选择课程"
class="w-full"
:loading="referenceDataLoading"
>
<el-option
v-for="item in subjectOptions"
:key="item.kbh"
:label="item.kmc"
:value="item.kbh"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学期第次" prop="xqdc">
<el-input-number v-model="dialog.form.xqdc" :min="1" :max="20" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课类型" prop="klx">
<el-select
v-model="dialog.form.klx"
filterable
clearable
placeholder="请选择课类型"
class="w-full"
:loading="referenceDataLoading"
>
<el-option
v-for="item in courseTypeOptions"
:key="item.dictValue"
:label="item.dictLabel"
:value="item.dictValue"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学时" prop="xs">
<el-input-number v-model="dialog.form.xs" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="停用" prop="ty">
<el-radio-group v-model="dialog.form.ty">
<el-radio :label="0">否</el-radio>
<el-radio :label="1">是</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="简称">
<el-input v-model="dialog.form.jc" placeholder="请输入课程简称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学分">
<el-input-number v-model="dialog.form.xf" :min="0" :step="0.5" :precision="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课程定位">
<el-input v-model="dialog.form.kcdw" placeholder="请输入课程定位" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="模块">
<el-input v-model="dialog.form.mk" placeholder="请输入模块" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="成绩分制">
<el-input v-model="dialog.form.cjfz" placeholder="请输入成绩分制" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="教研室">
<el-select
v-model="dialog.form.jysdh"
filterable
clearable
placeholder="请选择教研室"
class="w-full"
:loading="referenceDataLoading"
>
<el-option
v-for="item in officeOptions"
:key="item.jysdh"
:label="item.jysmc"
:value="item.jysdh"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="考试课时">
<el-input-number v-model="dialog.form.ksks" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="理论学时">
<el-input-number v-model="dialog.form.llxs" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="实践学时">
<el-input-number v-model="dialog.form.sjxs" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="周课时">
<el-input-number v-model="dialog.form.zks" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="不计入平均分">
<el-radio-group v-model="dialog.form.bjrxypjf">
<el-radio :label="0">否</el-radio>
<el-radio :label="1">是</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="考试课时不显示">
<el-radio-group v-model="dialog.form.ksksbxs">
<el-radio :label="0">否</el-radio>
<el-radio :label="1">是</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="大纲课程">
<el-radio-group v-model="dialog.form.dgkc">
<el-radio :label="0">否</el-radio>
<el-radio :label="1">是</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="dialog.visible = false">取 消</el-button>
<el-button type="primary" :loading="dialog.submitting" @click="submitDialog">确 定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
addSyllabus,
deleteSyllabus,
batchDeleteSyllabus,
updateSyllabus,
listByZydhAndTy
} from '@/api/teachBusiness/syllabus'
import { listMajor } from '@/api/subjectMajor/major'
import { listKb } from '@/api/teachOffice/kb'
import { listOffice } from '@/api/teachOffice/office'
import { getDicts } from '@/api/system/dict/data'
const REFERENCE_DATA_PAGE_SIZE = 10000
const COURSE_TYPE_DICT_CODE = 'course_type'
export default {
name: 'SyllabusIndex',
data() {
return {
loading: false,
searchForm: {
zydh: '',
ty: 0
},
tableData: [],
total: 0,
pageNum: 1,
pageSize: 20,
localPaging: false,
selection: [],
referenceDataLoading: false,
majorOptions: [],
subjectOptions: [],
courseTypeOptions: [],
officeOptions: [],
dialog: {
visible: false,
title: '',
submitting: false,
form: this.createEmptyForm()
},
rules: {
zydh: [{ required: true, message: '请选择专业', trigger: 'change' }],
kbh: [{ required: true, message: '请选择课程', trigger: 'change' }],
xqdc: [{ required: true, message: '请输入学期第次', trigger: 'blur' }],
klx: [{ required: true, message: '请选择课类型', trigger: 'change' }],
xs: [{ required: true, message: '请输入学时', trigger: 'blur' }],
ty: [{ required: true, message: '请选择停用标识', trigger: 'change' }]
}
}
},
computed: {
pagedData() {
if (!this.localPaging) {
return this.tableData
}
const start = (this.pageNum - 1) * this.pageSize
return this.tableData.slice(start, start + this.pageSize)
}
},
created() {
this.fetchList()
this.loadReferenceData()
},
methods: {
loadReferenceData() {
this.referenceDataLoading = true
const query = { pageNum: 1, pageSize: REFERENCE_DATA_PAGE_SIZE }
const requests = [
listMajor(query),
listKb(query),
listOffice(query),
getDicts(COURSE_TYPE_DICT_CODE)
].map(request => {
return request
.then(response => this.getResponseRecords(response))
.catch(() => [])
})
Promise.all(requests).then(([majors, subjects, offices, courseTypes]) => {
this.majorOptions = this.getUniqueOptions(majors, 'zydh', 'zymc')
this.subjectOptions = this.getUniqueOptions(subjects, 'kbh', 'kmc')
this.officeOptions = this.getUniqueOptions(offices, 'jysdh', 'jysmc')
this.courseTypeOptions = this.getUniqueOptions(courseTypes, 'dictValue', 'dictLabel')
}).finally(() => {
this.referenceDataLoading = false
})
},
getResponseRecords(response) {
const data = response.data || {}
return Array.isArray(data) ? data : data.records || []
},
getUniqueOptions(items, valueKey, labelKey) {
const values = new Set()
return items.filter(item => {
const value = item[valueKey]
const label = item[labelKey]
if (!value || !label || values.has(value)) {
return false
}
values.add(value)
return true
})
},
/* ---------- 列表加载 ---------- */
fetchList() {
this.loading = true
listByZydhAndTy({ zydh: this.searchForm.zydh, ty: this.searchForm.ty }).then(response => {
const data = response.data || []
this.tableData = Array.isArray(data) ? data : (data.records || [])
this.total = this.tableData.length
this.localPaging = true
}).catch(() => {
this.tableData = []
this.total = 0
}).finally(() => {
this.loading = false
})
},
handleQuery() {
this.pageNum = 1
this.fetchList()
},
handleReset() {
this.searchForm = { zydh: '', ty: 0 }
this.pageNum = 1
this.fetchList()
},
handleSelectionChange(val) {
this.selection = val
},
/* ---------- 新增 / 编辑 ---------- */
handleAdd() {
this.dialog.title = '新增教学大纲'
this.dialog.form = this.createEmptyForm()
this.dialog.visible = true
this.$nextTick(() => {
if (this.$refs.syllabusForm) this.$refs.syllabusForm.clearValidate()
})
},
handleEdit(row) {
this.dialog.title = '编辑教学大纲'
this.dialog.form = Object.assign({}, this.createEmptyForm(), row)
this.dialog.visible = true
this.$nextTick(() => {
if (this.$refs.syllabusForm) this.$refs.syllabusForm.clearValidate()
})
},
submitDialog() {
this.$refs.syllabusForm.validate(valid => {
if (!valid) return
this.dialog.submitting = true
const payload = this.cleanPayload(this.dialog.form)
const isEdit = !!payload.bh
const req = isEdit ? updateSyllabus(payload) : addSyllabus(payload)
req.then(() => {
this.$message.success(isEdit ? '修改成功' : '新增成功')
this.dialog.visible = false
this.fetchList()
}).finally(() => {
this.dialog.submitting = false
})
})
},
/* ---------- 删除 / 批量删除 ---------- */
handleDelete(row) {
this.$confirm('删除后该条记录将置为「停用」,是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
return deleteSyllabus(row.bh)
}).then(() => {
this.$message.success('删除成功')
this.fetchList()
}).catch(() => {})
},
handleBatchDelete() {
if (!this.selection.length) {
this.$message.warning('请先勾选要删除的记录')
return
}
const bhList = this.selection.map(row => row.bh)
this.$confirm('确定删除所选 ' + bhList.length + ' 条记录吗?(将置为「停用」)', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
return batchDeleteSyllabus(bhList)
}).then(() => {
this.$message.success('批量删除成功')
this.fetchList()
}).catch(() => {})
},
/* ---------- 下载(无后端接口) ---------- */
handleDownload() {
this.$message.warning('后端暂未提供该接口')
},
/* ---------- 分页 ---------- */
handleSizeChange(size) {
this.pageSize = size
this.pageNum = 1
if (!this.localPaging) {
this.fetchList()
}
},
handlePageChange(page) {
this.pageNum = page
if (!this.localPaging) {
this.fetchList()
}
},
/* ---------- 工具 ---------- */
createEmptyForm() {
return {
bh: '',
zydh: '',
kbh: '',
xqdc: 1,
klx: '',
xs: 0,
ty: 0,
jc: '',
xf: null,
kcdw: '',
ksks: 0,
mk: '',
cjfz: '',
bjrxypjf: 0,
llxs: 0,
sjxs: 0,
zks: 0,
ksksbxs: 0,
dgkc: 0,
jysdh: ''
}
},
/** 移除空值(''/null/undefined),保留 0 等有效值 */
cleanPayload(obj) {
const payload = {}
Object.keys(obj).forEach(key => {
const value = obj[key]
if (value !== '' && value !== null && value !== undefined) {
payload[key] = value
}
})
return payload
},
fmtYesNo(val) {
return Number(val) === 1 ? '是' : '否'
},
fmtDateTime(val) {
return (val || '').substring(0, 16) || '-'
}
}
}
</script>
<style scoped lang="scss">
.app-container {
padding: 20px;
}
.syllabus-page {
.search-card {
margin-bottom: 16px;
.search-form {
.w-full {
width: 100%;
}
.search-actions {
padding-top: 4px;
}
}
}
.table-card {
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.list-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.pagination {
margin-top: 16px;
text-align: right;
}
}
}
.danger-text-btn {
color: #f56c6c;
&:hover {
color: #f78989;
}
}
</style>
@@ -0,0 +1,297 @@
<template>
<div class="app-container teaching-task-page">
<!-- ==================== 页面标题 ==================== -->
<div class="page-title">教学任务列表</div>
<!-- ==================== 1. 查询条件区域 ==================== -->
<el-card shadow="never" class="search-card">
<el-form :model="searchForm" label-width="80px" class="search-form">
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :md="8">
<el-form-item label="任务名称">
<el-input
v-model="searchForm.rwmc"
placeholder="请输入任务名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-form-item label="年度">
<el-select
v-model="searchForm.nd"
placeholder="请选择年度"
clearable
filterable
style="width: 100%"
>
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-form-item label="状态">
<el-select
v-model="searchForm.zt"
placeholder="请选择状态"
clearable
style="width: 100%"
>
<el-option
v-for="opt in statusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<div class="search-actions">
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
</div>
</el-form>
</el-card>
<!-- ==================== 2. 数据表格 ==================== -->
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="tableData" border stripe>
<template slot="empty">
<span>无数据!</span>
</template>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="bh" label="编号" width="100" align="center" show-overflow-tooltip />
<el-table-column prop="rwmc" label="任务名称" width="250" align="center" show-overflow-tooltip />
<el-table-column prop="nd" label="年度" width="150" align="center" />
<el-table-column label="状态" width="100" align="center">
<template slot-scope="{ row }">
<el-tag :type="row.zt === '已发布' ? 'success' : 'info'" size="small">
{{ row.zt || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="fbsj" label="发布时间" width="200" align="center" :formatter="fmtDateTime" />
<el-table-column prop="jssj" label="结束时间" width="200" align="center" :formatter="fmtDateTime" />
<el-table-column prop="cjsj" label="创建时间" width="200" align="center" :formatter="fmtDateTime" />
<el-table-column prop="jcxqscsj" label="教材需求生成时间" width="200" align="center" :formatter="fmtDateTime" />
<el-table-column label="操作" align="center" fixed="right">
<template slot-scope="{ row }">
<el-button type="text" size="small" @click="handleDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="pageNum"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
class="pagination"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</el-card>
<!-- ==================== 3. 详情对话框 ==================== -->
<el-dialog
:visible="detailVisible"
title="教学任务详情"
width="620px"
:close-on-click-modal="false"
@update:visible="val => detailVisible = val"
>
<el-descriptions v-if="detailData.bh" :column="2" border>
<el-descriptions-item label="编号">{{ detailData.bh || '-' }}</el-descriptions-item>
<el-descriptions-item label="任务名称">{{ detailData.rwmc || '-' }}</el-descriptions-item>
<el-descriptions-item label="年度">{{ fmtVal(detailData.nd) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detailData.zt || '-' }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ fmtVal(detailData.fbsj) }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ fmtVal(detailData.jssj) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ fmtVal(detailData.cjsj) }}</el-descriptions-item>
<el-descriptions-item label="教材需求生成时间">{{ fmtVal(detailData.jcxqscsj) }}</el-descriptions-item>
</el-descriptions>
<div v-else v-loading="detailLoading" class="detail-empty">加载中...</div>
<div slot="footer">
<el-button @click="detailVisible = false">关闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
/**
* 教学任务列表(只读查询页)
* 对应菜单:teachBusiness/teachingTask(原「分组课列表」页,下载类功能后端无接口已移除)
* 仅提供 分页查询 list + 详情 get?bh=,不做增删改发(避免与教学任务计划管理页重复操作同一实体)
* 增删改发统一在 src/views/teachOffice/taskPlan/index.vue(教学任务计划管理)完成
*/
import { listTeachingTask, getTeachingTask } from '@/api/teachBusiness/teachingTask'
import { listAllSemester } from '@/api/teachBusiness/semester'
export default {
name: 'TeachingTask',
data() {
return {
// ==================== 1. 查询条件 ====================
searchForm: {
rwmc: '',
nd: undefined,
zt: ''
},
yearOptions: [],
statusOptions: [
{ label: '未发布', value: '未发布' },
{ label: '已发布', value: '已发布' }
],
// ==================== 2. 表格数据 ====================
loading: false,
tableData: [],
pageNum: 1,
pageSize: 10,
total: 0,
// ==================== 3. 详情 ====================
detailVisible: false,
detailLoading: false,
detailData: {}
}
},
created() {
this.loadYearOptions().then(() => this.fetchList())
},
methods: {
/* ---------- 年度下拉(数据来自 /semester/all,禁止硬编码) ---------- */
loadYearOptions() {
return listAllSemester().then(response => {
const list = response.data || []
const map = {}
list.forEach(item => {
if (item.nd !== null && item.nd !== undefined && item.nd !== '') map[item.nd] = item
})
const arr = Object.keys(map).sort((a, b) => String(b).localeCompare(String(a)))
this.yearOptions = arr
// 默认年度:优先当前学期(dqxq=true),否则取最新年度
const current = list.find(item => item.dqxq === true)
const defaultNd = (current && current.nd) || arr[0]
if (!this.searchForm.nd) this.searchForm.nd = defaultNd
return arr
}).catch(() => {
this.yearOptions = []
return []
})
},
/* ---------- 通用格式化 ---------- */
fmtDateTime(row, column, cellValue) {
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
return String(cellValue).replace('T', ' ').slice(0, 16)
},
fmtVal(val) {
if (val === null || val === undefined || val === '') return '-'
return String(val).replace('T', ' ').slice(0, 16)
},
/* ---------- 列表加载 ---------- */
fetchList() {
this.loading = true
const params = { pageNum: this.pageNum, pageSize: this.pageSize }
if (this.searchForm.rwmc && this.searchForm.rwmc.trim()) params.rwmc = this.searchForm.rwmc.trim()
if (this.searchForm.nd !== undefined && this.searchForm.nd !== null && this.searchForm.nd !== '') {
params.nd = this.searchForm.nd
}
if (this.searchForm.zt && this.searchForm.zt.trim()) params.zt = this.searchForm.zt.trim()
listTeachingTask(params).then(res => {
const data = (res && res.data) || {}
this.tableData = data.records || []
this.total = data.total || 0
this.loading = false
}).catch(() => {
this.tableData = []
this.total = 0
this.loading = false
})
},
handleQuery() {
this.pageNum = 1
this.fetchList()
},
handlePageChange(page) {
this.pageNum = page
this.fetchList()
},
handleSizeChange(size) {
this.pageSize = size
this.pageNum = 1
this.fetchList()
},
/* ---------- 详情(只读,走真实 get?bh= 接口) ---------- */
handleDetail(row) {
this.detailVisible = true
this.detailLoading = true
this.detailData = {}
getTeachingTask(row.bh).then(res => {
this.detailData = (res && res.data) || {}
this.detailLoading = false
}).catch(() => {
this.detailLoading = false
})
}
}
}
</script>
<style scoped lang="scss">
.app-container {
padding: 20px;
}
.teaching-task-page {
.page-title {
text-align: center;
font-size: 20px;
font-weight: 600;
color: #303133;
margin-bottom: 20px;
}
// ==================== 1. 查询条件区域 ====================
.search-card {
margin-bottom: 16px;
.search-form {
.search-actions {
display: flex;
justify-content: flex-end;
padding-top: 12px;
border-top: 1px solid #ebeef5;
}
}
}
// ==================== 2. 数据表格 ====================
.table-card {
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
}
// ==================== 3. 详情 ====================
.detail-empty {
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
}
}
</style>
@@ -0,0 +1,466 @@
<template>
<div class="app-container timetable-page">
<el-tabs v-model="activeTab" class="timetable-tabs">
<!-- ==================== 今日教学实施计划 ==================== -->
<el-tab-pane label="今日教学实施计划" name="today">
<div class="tab-body">
<div class="date-header">
<el-link type="primary" :underline="false" class="nav-link" @click="handlePrevDay">前一天</el-link>
<div class="date-title">{{ dateTitle }}</div>
<el-link type="primary" :underline="false" class="nav-link" @click="handleNextDay">后一天</el-link>
</div>
<div class="table-card">
<el-table :data="todayData" row-key="bh" v-loading="loading" stripe border>
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column label="上课时间" width="130" align="center">
<template #default="{ row }">{{ row.sjsj || '-' }}</template>
</el-table-column>
<el-table-column prop="kcmc" label="课程" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="zrdw" label="责任单位" width="110" align="center" />
<el-table-column prop="bc" label="班次" width="120" align="center" show-overflow-tooltip />
<el-table-column prop="skjy" label="教员" width="90" align="center" />
<el-table-column label="场地" width="140" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.jxcd || '-' }}</template>
</el-table-column>
<el-table-column label="教学内容方法" min-width="160" show-overflow-tooltip header-align="center">
<template #default="{ row }">{{ row.jxnrff || '-' }}</template>
</el-table-column>
<el-table-column prop="jybz" label="备注" width="100" show-overflow-tooltip header-align="center">
<template #default="{ row }">{{ row.jybz || '-' }}</template>
</el-table-column>
</el-table>
<el-empty v-show="!loading && !todayData.length" description="暂无教学计划数据" />
</div>
</div>
</el-tab-pane>
<!-- ==================== 本周教学实施计划 ==================== -->
<el-tab-pane label="本周教学实施计划" name="week">
<div class="tab-body">
<div class="week-header">
<el-link type="primary" :underline="false" class="nav-link" @click="handlePrevWeek">上一周</el-link>
<div class="week-title">{{ weekRangeText }}</div>
<el-link type="primary" :underline="false" class="nav-link" @click="handleNextWeek">下一周</el-link>
</div>
<div class="table-card">
<el-table :data="weekData" row-key="bh" v-loading="loading" stripe border>
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column label="上课时间" width="170" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.sjsj || '-' }}</template>
</el-table-column>
<el-table-column prop="kcmc" label="课程" min-width="130" align="center" show-overflow-tooltip />
<el-table-column prop="zrdw" label="责任单位" width="100" align="center" />
<el-table-column prop="bc" label="班次" width="130" align="center" show-overflow-tooltip />
<el-table-column prop="skjy" label="教员" width="90" align="center" />
<el-table-column label="场地" width="140" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.jxcd || '-' }}</template>
</el-table-column>
<el-table-column label="教学内容方法" min-width="150" show-overflow-tooltip header-align="center">
<template #default="{ row }">{{ row.jxnrff || '-' }}</template>
</el-table-column>
<el-table-column prop="jybz" label="备注" width="100" show-overflow-tooltip header-align="center">
<template #default="{ row }">{{ row.jybz || '-' }}</template>
</el-table-column>
</el-table>
<el-empty v-show="!loading && !weekData.length" description="本周暂无教学计划数据" />
</div>
</div>
</el-tab-pane>
<!-- ==================== 教学班次教学实施计划 ==================== -->
<el-tab-pane label="教学班次教学实施计划" name="shift">
<div class="tab-body">
<!-- 选择条件 -->
<div class="search-card">
<el-form :model="searchForm" inline class="search-form">
<el-form-item label="队别班次">
<el-select v-model="searchForm.teamClass" placeholder="请选择队别班次" clearable filterable class="shift-select">
<el-option v-for="item in teamClassOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button>
</el-form-item>
</el-form>
</div>
<!-- 标题 -->
<div class="page-title">{{ pageTitle }}</div>
<!-- 下载操作按钮 -->
<el-card shadow="never" class="action-card">
<div class="button-row">
<el-button type="primary" plain size="mini" @click="handleExportPlan">学期教学实施计划</el-button>
<el-button type="primary" plain size="mini" @click="handleExportGrades">学期成绩表</el-button>
<el-button type="primary" plain size="mini" @click="handleExportCourses">课程列表另存Excel</el-button>
</div>
</el-card>
<!-- 数据表格 -->
<div class="table-card">
<el-table :data="shiftData" v-loading="loading" border stripe style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="课程名称/课时系数" min-width="220" align="center">
<template #default="{ row }">
<div>{{ row.kcmc || '-' }}</div>
<div class="sub-text">{{ row.kssk || '-' }}</div>
</template>
</el-table-column>
<el-table-column prop="zrjs" label="责任教员/课次" min-width="150" align="center" show-overflow-tooltip />
<el-table-column prop="bc" label="班次" min-width="120" align="center" show-overflow-tooltip />
<el-table-column prop="nj" label="年级" width="80" align="center" />
<el-table-column prop="zy" label="专业" min-width="120" align="center" show-overflow-tooltip />
<el-table-column prop="jhxs" label="计划学时" width="90" align="center" />
<el-table-column prop="yxxs" label="运行学时" width="90" align="center" />
<el-table-column prop="khlxfs" label="考核类型方式" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="ssjs" label="实施教员" min-width="120" align="center" show-overflow-tooltip />
<el-table-column prop="rscd" label="人数/场地" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="kcjd" label="课程进度" width="120" align="center" />
<el-table-column prop="ssjhbg" label="实施计划变更" min-width="120" align="center" show-overflow-tooltip />
</el-table>
<el-empty v-show="!loading && !shiftData.length" description="请选择队别班次后查询" />
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
import { listToday, listWeek, listClass, exportClassPlan, exportClassGrades, exportClassCourses } from '@/api/teachBusiness/timetable'
import { listTeam } from '@/api/studentRecords/team'
const WEEK_DAY_NAMES = ['日', '一', '二', '三', '四', '五', '六']
function pad2(n) {
return n < 10 ? '0' + n : '' + n
}
function formatDate(d) {
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
}
function getMonday(d) {
const day = d.getDay() || 7
const monday = new Date(d)
monday.setDate(d.getDate() - day + 1)
return monday
}
export default {
name: 'Timetable',
data() {
return {
activeTab: 'today',
loading: false,
// 今日
todayDate: new Date(),
dayOffset: 0,
todayData: [],
// 本周
weekStart: getMonday(new Date()),
weekOffset: 0,
weekData: [],
// 班次
searchForm: { teamClass: '' },
teamClassOptions: [],
teamMap: {},
shiftData: []
}
},
computed: {
selectedTodayDate() {
const date = new Date(this.todayDate)
date.setDate(date.getDate() + this.dayOffset)
return date
},
selectedWeekStart() {
const date = new Date(this.weekStart)
date.setDate(date.getDate() + this.weekOffset * 7)
return date
},
dateTitle() {
return formatDate(this.selectedTodayDate) + ' 今日教学实施计划'
},
weekRangeText() {
const end = new Date(this.selectedWeekStart)
end.setDate(end.getDate() + 6)
return formatDate(this.selectedWeekStart) + '(' + this.weekDayName(this.selectedWeekStart) + ') 至 ' +
formatDate(end) + '(' + this.weekDayName(end) + ') 本周教学实施计划'
},
pageTitle() {
const name = this.searchForm.teamClass || ''
return (name ? name + ' ' : '') + '教学班次教学实施计划'
}
},
created() {
this.loadTeamOptions()
this.getTodayList()
this.getWeekList()
},
methods: {
weekDayName(d) {
return WEEK_DAY_NAMES[d.getDay()]
},
/* ---------- 今日:日期导航 ---------- */
handlePrevDay() {
this.dayOffset -= 1
this.getTodayList()
},
handleNextDay() {
this.dayOffset += 1
this.getTodayList()
},
/* ---------- 本周:周次导航 ---------- */
handlePrevWeek() {
this.weekOffset -= 1
this.getWeekList()
},
handleNextWeek() {
this.weekOffset += 1
this.getWeekList()
},
/* ---------- 队别班次下拉(真实接口) ---------- */
loadTeamOptions() {
listTeam({ pageNum: 1, pageSize: 1000 }).then(response => {
const data = response.data || {}
const list = data.records || []
const map = {}
;(list || []).forEach(item => {
if (item.xydmc) map[item.xydmc] = item.xydbh
})
this.teamMap = map
this.teamClassOptions = list.map(item => item.xydmc).filter(Boolean)
}).catch(() => {
this.teamMap = {}
this.teamClassOptions = []
})
},
/* ---------- 数据字段映射(接口字段 -> 页面字段) ---------- */
mapLessonRow(apiRow) {
// 接口今日/本周实体:bh sksj kc zrdw bc jy cd jxnrxff bz
const row = Object.assign({}, apiRow)
row.kcmc = apiRow.kc
row.skjy = apiRow.jy
row.jxcd = apiRow.cd
row.jxnrff = apiRow.jxnrxff
row.jybz = apiRow.bz
row.sjsj = apiRow.sksj
return row
},
extractLessonRows(response) {
const data = response && response.data
if (Array.isArray(data)) {
return data
}
if (!data) {
return []
}
// 新接口返回 DailyWeeklyTimetableRangeVO,列表位于 data.list;
// records 分支用于兼容旧分页结构,避免后端版本切换时页面再次无数据。
if (Array.isArray(data.list)) {
return data.list
}
if (Array.isArray(data.records)) {
return data.records
}
return []
},
mapClassRow(apiRow) {
// 接口班次实体:sskcbh kcmcksxs zrjykc jhxs yxxs khlxfs ssjy rscd ssjhbg
const row = Object.assign({}, apiRow)
const parts = String(apiRow.kcmcksxs || '').split(' / ')
row.kcmc = parts[0] || '-'
row.kssk = parts[1] || '-'
row.zrjs = apiRow.zrjykc
row.ssjs = apiRow.ssjy
return row
},
/* ---------- 今日:拉取数据 ---------- */
getTodayList() {
const params = {
offset: this.dayOffset,
rq: formatDate(this.todayDate)
}
this.loading = true
listToday(params).then(response => {
this.todayData = this.extractLessonRows(response).map(item => this.mapLessonRow(item))
this.loading = false
}).catch(() => {
this.todayData = []
this.loading = false
})
},
/* ---------- 本周:拉取数据 ---------- */
getWeekList() {
const params = {
offset: this.weekOffset,
rq: formatDate(this.weekStart)
}
this.loading = true
listWeek(params).then(response => {
this.weekData = this.extractLessonRows(response).map(item => this.mapLessonRow(item))
this.loading = false
}).catch(() => {
this.weekData = []
this.loading = false
})
},
/* ---------- 班次:拉取数据 ---------- */
getShiftList() {
if (!this.searchForm.teamClass) return
const xydbh = this.teamMap[this.searchForm.teamClass] || this.searchForm.teamClass
const params = { xydbh: xydbh, pageNum: 1, pageSize: 1000 }
this.loading = true
listClass(params).then(response => {
const data = response.data || {}
this.shiftData = (data.records || []).map(item => this.mapClassRow(item))
this.loading = false
}).catch(() => {
this.shiftData = []
this.loading = false
})
},
/* ---------- 班次:查询 ---------- */
handleQuery() {
if (!this.searchForm.teamClass) {
this.$message.warning('请先选择队别班次')
return
}
this.getShiftList()
},
/* ---------- 下载 ---------- */
downloadBlob(blob, filename) {
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(link.href)
},
shiftQueryParams() {
if (!this.searchForm.teamClass) return null
const xydbh = this.teamMap[this.searchForm.teamClass] || this.searchForm.teamClass
return { xydbh: xydbh }
},
/** 下载学期教学实施计划 Excel(GET /kcb/class/export-plan) */
handleExportPlan() {
const params = this.shiftQueryParams()
if (!params) {
this.$message.warning('请先选择队别班次')
return
}
exportClassPlan(params).then(blob => {
this.downloadBlob(blob, '学期教学实施计划.xlsx')
this.$message.success('学期教学实施计划导出成功')
}).catch(() => {})
},
/** 下载学期成绩表 Excel(GET /kcb/class/export-grades) */
handleExportGrades() {
const params = this.shiftQueryParams()
if (!params) {
this.$message.warning('请先选择队别班次')
return
}
exportClassGrades(params).then(blob => {
this.downloadBlob(blob, '学期成绩表.xlsx')
this.$message.success('学期成绩表导出成功')
}).catch(() => {})
},
/** 班次课程列表另存 Excel(GET /kcb/class/export-courses) */
handleExportCourses() {
const params = this.shiftQueryParams()
if (!params) {
this.$message.warning('请先选择队别班次')
return
}
exportClassCourses(params).then(blob => {
this.downloadBlob(blob, '班次课程列表.xlsx')
this.$message.success('班次课程列表导出成功')
}).catch(() => {})
}
}
}
</script>
<style scoped lang="scss">
.timetable-page {
.timetable-tabs {
background: #fff;
padding: 0 16px;
border: 1px solid #ebeef5;
border-radius: 6px;
}
.tab-body {
padding-bottom: 16px;
}
.sub-text {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
/* 日期/周次导航 */
.date-header,
.week-header {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
margin-bottom: 12px;
padding: 14px 16px;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
.nav-link {
font-size: 14px;
}
.date-title,
.week-title {
font-size: 16px;
font-weight: 700;
color: #303133;
}
}
/* 班次 */
.search-card {
margin-bottom: 12px;
.search-form {
margin-bottom: 0;
}
.shift-select {
width: 240px;
}
}
.page-title {
font-size: 17px;
font-weight: 700;
color: #303133;
text-align: center;
margin-bottom: 12px;
}
.action-card {
margin-bottom: 12px;
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
}
</style>
@@ -0,0 +1,951 @@
<template>
<div class="page-container">
<!-- 统计卡片 -->
<div class="section-card">
<div class="stat-grid">
<div
v-for="card in statCards"
:key="card.title"
class="stat-card"
:style="{ background: card.gradient }"
tabindex="0"
role="button"
@click="handleStatClick(card)"
@keydown.enter="handleStatClick(card)"
>
<span class="stat-text">{{ card.title }}</span>
<span class="stat-value">{{ card.value }}</span>
</div>
</div>
</div>
<!-- 查询条件 -->
<div class="section-card">
<el-form :model="queryParams" :inline="true" size="small" class="search-form" @submit.native.prevent>
<el-form-item label="年度">
<el-select
v-model="queryParams.nd"
placeholder="请选择年度"
clearable
filterable
style="width: 140px"
>
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
</el-form-item>
<el-form-item label="申请教员编号">
<el-input v-model="queryParams.sqjybh" placeholder="请输入申请教员编号" clearable style="width: 180px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="教研室审批状态">
<el-select v-model="queryParams.jyspzzt" placeholder="请选择" clearable style="width: 150px">
<el-option v-for="item in auditStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 调课调整申请列表 -->
<div class="section-card">
<div class="section-header">
<div class="section-title">调停补课申请列表</div>
<div class="header-actions">
<el-button type="primary" plain icon="el-icon-plus" size="small" @click="openAdd">新增申请</el-button>
</div>
</div>
<el-table :data="tableData" v-loading="loading" size="small" border stripe class="compact-table" max-height="520">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="kcmc" label="课程名称" width="200" align="center" show-overflow-tooltip />
<el-table-column label="申请教员" width="150" align="center" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.sqjyxm || scope.row.sqjybh || '-' }}</template>
</el-table-column>
<el-table-column prop="sy" label="调课事由" width="230" show-overflow-tooltip header-align="center" />
<el-table-column label="拟调整时间" width="200" align="center" show-overflow-tooltip>
<template slot-scope="scope">{{ fmtRqJc(scope.row.rq, scope.row.jc) }}</template>
</el-table-column>
<el-table-column prop="nd" label="年度" width="150" align="center" />
<el-table-column label="教研室审批状态" width="150" align="center">
<template slot-scope="scope">
<el-tag :type="auditStatusTag(scope.row.jyspzzt).type" size="mini">{{ auditStatusTag(scope.row.jyspzzt).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="教研室查收状态" width="150" align="center">
<template slot-scope="scope">
<el-tag :type="receiveStatusTag(scope.row.jyscszt).type" size="mini">{{ receiveStatusTag(scope.row.jyscszt).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="150" align="center">
<template slot-scope="scope">{{ fmtDateTime(scope.row.cjsj) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template slot-scope="scope">
<div class="ops-cell">
<el-button type="text" size="mini" icon="el-icon-view" @click="openDetail(scope.row)">详情</el-button>
<el-button v-if="showAuditMenu(scope.row)" type="text" size="mini" @click="openAudit(scope.row)">审批</el-button>
<el-button v-if="scope.row.sczt === 0 && scope.row.jyspzzt !== 1 && scope.row.jyspzzt !== 3" type="text" size="mini" class="danger-btn" @click="handleCancel(scope.row)">撤销</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-empty v-show="!loading && tableData.length === 0" description="暂无调整申请" />
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="loadList"
/>
</div>
<!-- 新增申请弹窗 -->
<el-dialog title="新增课表调整申请" :visible.sync="addVisible" width="640px" append-to-body>
<el-form ref="addForm" :model="addForm" :rules="addRules" label-width="110px">
<el-form-item label="年度" prop="nd">
<el-select
v-model="addForm.nd"
placeholder="请选择年度"
filterable
style="width: 100%"
@change="handleAddYearChange"
>
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
</el-form-item>
<el-form-item label="课次编号" prop="sskcbbh">
<el-input v-model="addForm.sskcbbh" placeholder="请从可供调课课次中选择" disabled />
<el-button type="primary" plain size="mini" class="pick-lesson-btn" :loading="lessonLoading" @click="loadAdjustableLessons">选择可供调课课次</el-button>
</el-form-item>
<el-form-item label="调课事由" prop="sy">
<el-input
v-model="addForm.sy"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入调课事由"
/>
</el-form-item>
<el-form-item label="拟调整日期" prop="rq">
<el-date-picker v-model="addForm.rq" type="date" value-format="yyyy-MM-dd" placeholder="请选择日期" style="width: 100%" />
</el-form-item>
<el-form-item label="拟调整节次" prop="jc">
<el-select v-model="addForm.jc" placeholder="请选择节次" style="width: 100%">
<el-option v-for="n in jcOptions" :key="n" :label="jcLabel(n)" :value="n" />
</el-select>
</el-form-item>
<el-form-item label="申请教员">
<el-input v-model="addForm.sqjybh" placeholder="由服务端根据当前登录人写入" disabled />
</el-form-item>
<el-form-item label="学员队名称">
<el-input v-model="addForm.xydmc" placeholder="选择可供调课课次后自动带出" disabled />
</el-form-item>
<el-form-item label="原教室编号">
<el-input v-model="addForm.yjsbh" placeholder="选择可供调课课次后自动带出" disabled />
</el-form-item>
<el-form-item label="拟调整教室编号">
<el-select
v-model="addForm.xjsbh"
placeholder="请选择拟调整教室"
clearable
filterable
:loading="classroomLoading"
style="width: 100%"
>
<el-option
v-for="room in classroomOptions"
:key="room.jsbh"
:label="classroomOptionLabel(room)"
:value="room.jsbh"
/>
</el-select>
</el-form-item>
<el-form-item label="原上课时间">
<el-input v-model="addForm.ydsjap" placeholder="选择可供调课课次后自动带出" disabled />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :loading="addLoading" @click="submitAdd">提交申请</el-button>
<el-button @click="addVisible = false">取消</el-button>
</div>
</el-dialog>
<!-- 可供调课课次选择弹窗 -->
<el-dialog title="选择可供调课课次" :visible.sync="lessonDialogVisible" width="760px" append-to-body>
<el-table :data="lessonOptions" v-loading="lessonLoading" size="small" border stripe max-height="420">
<el-table-column prop="kcmc" label="课程名称" min-width="140" align="center" show-overflow-tooltip />
<el-table-column label="原上课时间" width="160" align="center">
<template slot-scope="scope">{{ fmtRqJc(scope.row.rq, scope.row.jc) }}</template>
</el-table-column>
<el-table-column prop="xydmc" label="学员队" min-width="120" align="center" show-overflow-tooltip />
<el-table-column prop="kcxh" label="课次序号" width="90" align="center" />
<el-table-column prop="jsbh" label="教室编号" width="110" align="center" show-overflow-tooltip />
<el-table-column label="操作" width="90" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="pickLesson(scope.row)">选择</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-show="!lessonLoading && lessonOptions.length === 0" description="暂无可供调课课次" />
</el-dialog>
<!-- 审批弹窗 -->
<el-dialog title="教研室审批" :visible.sync="auditVisible" width="520px" append-to-body>
<el-form ref="auditForm" :model="auditForm" label-width="100px">
<el-form-item label="审批结果" required>
<el-radio-group v-model="auditForm.spzt">
<el-radio :label="1">同意</el-radio>
<el-radio :label="2">发回</el-radio>
<el-radio :label="3">拒绝</el-radio>
</el-radio-group>
<div class="form-tip">点审批即已查收</div>
</el-form-item>
<el-form-item label="审批意见">
<el-input v-model="auditForm.fhyj" type="textarea" :rows="3" placeholder="请输入审批意见(发回/拒绝时必填)" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :loading="auditLoading" @click="submitAudit">确定</el-button>
<el-button @click="auditVisible = false">取消</el-button>
</div>
</el-dialog>
<!-- 查看详情弹窗 -->
<el-dialog title="申请详情" :visible.sync="detailVisible" width="760px" append-to-body>
<div v-loading="detailLoading">
<el-descriptions v-if="detailData" :column="2" border size="small">
<el-descriptions-item label="课程名称">{{ detailData.kcmc || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请教员">{{ detailData.sqjyxm || detailData.sqjybh || '-' }}</el-descriptions-item>
<el-descriptions-item label="调课事由" :span="2">{{ detailData.sy || '-' }}</el-descriptions-item>
<el-descriptions-item label="原上课时间">{{ detailData.ydsjap || '-' }}</el-descriptions-item>
<el-descriptions-item label="拟调整时间">{{ fmtRqJc(detailData.rq, detailData.jc) }}</el-descriptions-item>
<el-descriptions-item label="年度">{{ detailData.nd || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ fmtDateTime(detailData.cjsj) }}</el-descriptions-item>
<el-descriptions-item label="教研室审批状态">
<el-tag :type="auditStatusTag(detailData.jyspzzt).type" size="mini">{{ auditStatusTag(detailData.jyspzzt).label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="教研室查收状态">
<el-tag :type="receiveStatusTag(detailData.jyscszt).type" size="mini">{{ receiveStatusTag(detailData.jyscszt).label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="教研室审批人">{{ detailData.jyspzrbh || '-' }}</el-descriptions-item>
<el-descriptions-item label="教研室审批时间">{{ fmtDateTime(detailData.jyspzsj) }}</el-descriptions-item>
<el-descriptions-item label="审批意见" :span="2">{{ detailData.fhyj || '-' }}</el-descriptions-item>
<el-descriptions-item label="教研室查收人">{{ detailData.jyscsrbh || '-' }}</el-descriptions-item>
<el-descriptions-item label="教研室查收时间">{{ fmtDateTime(detailData.jyscssj) }}</el-descriptions-item>
<el-descriptions-item label="教学内容" :span="2">{{ detailData.jxnr || '-' }}</el-descriptions-item>
<el-descriptions-item label="教学要点" :span="2">{{ detailData.jxyd || '-' }}</el-descriptions-item>
<el-descriptions-item label="教学方法" :span="2">{{ detailData.jxff || '-' }}</el-descriptions-item>
<el-descriptions-item label="教学保障备注" :span="2">{{ detailData.jxbzbz || '-' }}</el-descriptions-item>
<el-descriptions-item label="用车信息" :span="2">{{ detailData.ycxx || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detailData.bz || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 新教室 -->
<div v-if="detailData && detailData.roomList && detailData.roomList.length" class="sub-section">
<div class="sub-title">新教室</div>
<el-table :data="detailData.roomList" size="small" border stripe>
<el-table-column prop="jsbh" label="教室编号" min-width="120" align="center" show-overflow-tooltip />
<el-table-column prop="jsmc" label="教室名称" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="bz" label="备注" min-width="160" show-overflow-tooltip header-align="center" />
</el-table>
</div>
<!-- 新辅助教员 -->
<div v-if="detailData && detailData.teacherList && detailData.teacherList.length" class="sub-section">
<div class="sub-title">新辅助教员</div>
<el-table :data="detailData.teacherList" size="small" border stripe>
<el-table-column prop="fzjybh" label="教员编号" min-width="120" align="center" show-overflow-tooltip />
<el-table-column prop="fzjyxm" label="教员姓名" min-width="120" align="center" show-overflow-tooltip />
<el-table-column label="是否主教员" width="100" align="center">
<template slot-scope="scope">{{ scope.row.zjy === 1 ? '是' : '否' }}</template>
</el-table-column>
<el-table-column prop="bz" label="备注" min-width="140" show-overflow-tooltip header-align="center" />
</el-table>
</div>
<!-- 新学员队 -->
<div v-if="detailData && detailData.teamList && detailData.teamList.length" class="sub-section">
<div class="sub-title">新学员队</div>
<el-table :data="detailData.teamList" size="small" border stripe>
<el-table-column prop="xydbh" label="学员队编号" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="xydmc" label="学员队名称" min-width="140" align="center" show-overflow-tooltip />
</el-table>
</div>
<!-- 新保障明细 -->
<div v-if="detailData && detailData.supportList && detailData.supportList.length" class="sub-section">
<div class="sub-title">新保障明细</div>
<el-table :data="detailData.supportList" size="small" border stripe>
<el-table-column prop="bztgmxbh" label="保障编号" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="bztgmxmc" label="保障名称" min-width="160" align="center" show-overflow-tooltip />
<el-table-column prop="sl" label="数量" width="80" align="center" />
<el-table-column prop="bz" label="备注" min-width="140" show-overflow-tooltip header-align="center" />
</el-table>
</div>
<!-- 机关审批记录 -->
<div v-if="detailData && detailData.auditList && detailData.auditList.length" class="sub-section">
<div class="sub-title">机关审批记录</div>
<el-table :data="detailData.auditList" size="small" border stripe>
<el-table-column prop="jgmc" label="机关" min-width="140" align="center" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.jgmc || scope.row.jgbh || '-' }}</template>
</el-table-column>
<el-table-column label="审批状态" width="100" align="center">
<template slot-scope="scope">
<el-tag :type="auditStatusTag(scope.row.spzt).type" size="mini">{{ auditStatusTag(scope.row.spzt).label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sprxm" label="审批人" min-width="110" align="center" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.sprxm || scope.row.sprbh || '-' }}</template>
</el-table-column>
<el-table-column label="审批时间" width="150" align="center">
<template slot-scope="scope">{{ fmtDateTime(scope.row.spsj) }}</template>
</el-table-column>
<el-table-column prop="fhyj" label="发回意见" min-width="140" show-overflow-tooltip header-align="center" />
</el-table>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detailVisible = false">关闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listScheduleAdjust,
listAdjustableLessons,
getScheduleAdjustDetail,
submitScheduleAdjust,
auditByTeachingOffice,
cancelScheduleAdjust
} from '@/api/teachBusiness/courseRunning'
import { listAllSemester } from '@/api/teachBusiness/semester'
import { listAllClassroom } from '@/api/teachBusiness/classroom'
import { mapGetters } from 'vuex'
// 节次 -> 节次区间映射
const JC_SECTION = {
1: '1-2节', 2: '3-4节', 3: '5-6节', 4: '7-8节', 5: '9-10节', 6: '11-12节'
}
// 审批状态:0-未审批,1-已同意,2-已发回,3-已拒绝
const AUDIT_STATUS = {
0: { label: '未审批', type: 'info' },
1: { label: '已同意', type: 'success' },
2: { label: '已发回', type: 'warning' },
3: { label: '已拒绝', type: 'danger' }
}
// 查收状态:0-未查收,1-已查收
const RECEIVE_STATUS = {
0: { label: '未查收', type: 'info' },
1: { label: '已查收', type: 'success' }
}
export default {
name: 'TimetableAdjust',
data() {
return {
loading: false,
tableData: [],
total: 0,
queryParams: {
nd: undefined,
sqjybh: undefined,
jyspzzt: undefined,
pageNum: 1,
pageSize: 20
},
// 年度下拉数据来自 /semester/all(真实后端数据)
yearOptions: [],
defaultNd: undefined,
jcOptions: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
auditStatusOptions: [
{ label: '未审批', value: 0 },
{ label: '已同意', value: 1 },
{ label: '已发回', value: 2 },
{ label: '已拒绝', value: 3 }
],
statCards: this.createStatCards([0, 0, 0, 0]),
// 新增申请
addVisible: false,
addLoading: false,
addForm: {},
addRules: {
nd: [{ required: true, message: '请选择年度', trigger: 'change' }],
sskcbbh: [{ required: true, message: '请选择可供调课课次', trigger: 'change' }],
sy: [{ required: true, message: '请输入调课事由', trigger: 'blur' }],
rq: [{ required: true, message: '请选择拟调整日期', trigger: 'change' }],
jc: [{ required: true, message: '请选择拟调整节次', trigger: 'change' }]
},
// 可供调课课次
lessonDialogVisible: false,
lessonLoading: false,
lessonOptions: [],
classroomLoading: false,
classroomOptions: [],
// 审批
auditVisible: false,
auditLoading: false,
auditForm: { ssdksqbh: '', spzt: 1, fhyj: '' },
// 详情
detailVisible: false,
detailLoading: false,
detailData: null
}
},
computed: {
...mapGetters(['roles']),
isTeacher() {
const r = this.roles || []
const hasTeacher = r.includes('TEACHER') || r.includes('teacher') || r.includes('ROLE_TEACHER')
const hasAdmin = r.includes('admin') || r.includes('ROLE_ADMIN')
return hasTeacher && !hasAdmin
}
},
created() {
this.loadYearOptions().then(() => {
this.loadStatistics()
this.loadList()
})
},
methods: {
/* ---------- 年度下拉(数据来自 /semester/all) ---------- */
loadYearOptions() {
return listAllSemester().then(response => {
const list = response.data || []
// 去重并按年度倒序
const map = {}
list.forEach(item => {
if (item.nd !== null && item.nd !== undefined && item.nd !== '') map[item.nd] = item
})
const arr = Object.keys(map).sort((a, b) => String(b).localeCompare(String(a)))
this.yearOptions = arr
// 默认年度:优先当前学期(dqxq=true),否则取最新年度
const current = list.find(item => item.dqxq === true)
this.defaultNd = (current && current.nd) || arr[0]
if (!this.queryParams.nd) this.queryParams.nd = this.defaultNd
return arr
}).catch(() => {
this.yearOptions = []
this.defaultNd = undefined
return []
})
},
/* ---------- 通用格式化 ---------- */
fmtDateTime(v) {
if (!v) return '-'
return String(v).replace('T', ' ').substring(0, 16)
},
fmtRqJc(rq, jc) {
if (!rq) return '-'
const date = String(rq).substring(0, 10)
const sec = jc === null || jc === undefined || jc === '' ? '第?节' : (JC_SECTION[jc] || '第' + jc + '节')
return date + ' ' + sec
},
jcLabel(n) {
return JC_SECTION[n] || ('第' + n + '节')
},
auditStatusTag(v) {
return AUDIT_STATUS[v] || { label: '-', type: 'info' }
},
receiveStatusTag(v) {
return RECEIVE_STATUS[v] || { label: '-', type: 'info' }
},
/* ---------- 列表查询 ---------- */
loadList() {
const params = {
pageNum: this.queryParams.pageNum,
pageSize: this.queryParams.pageSize
}
if (this.queryParams.nd) params.nd = this.queryParams.nd
if (this.queryParams.sqjybh) params.sqjybh = this.queryParams.sqjybh
if (this.queryParams.jyspzzt !== undefined && this.queryParams.jyspzzt !== '') {
params.jyspzzt = this.queryParams.jyspzzt
}
this.loading = true
listScheduleAdjust(params).then(response => {
const data = response.data || {}
this.tableData = data.records || []
this.total = data.total || 0
this.loading = false
}).catch(() => {
this.tableData = []
this.total = 0
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.loadList()
},
resetQuery() {
this.queryParams = {
nd: this.defaultNd,
sqjybh: undefined,
jyspzzt: undefined,
pageNum: 1,
pageSize: 20
}
this.loadList()
},
loadStatistics() {
const baseParams = {
nd: this.defaultNd,
pageNum: 1,
pageSize: 1
}
const statusFilters = [undefined, 0, 1, 3]
const requests = statusFilters.map(status => {
const params = Object.assign({}, baseParams)
if (status !== undefined) {
params.jyspzzt = status
}
return listScheduleAdjust(params)
.then(response => Number((response.data && response.data.total) || 0))
.catch(() => 0)
})
return Promise.all(requests).then(counts => {
this.statCards = this.createStatCards(counts)
})
},
createStatCards(counts) {
return [
{
title: '本页申请',
value: counts[0],
gradient: 'linear-gradient(135deg, #00875a 0%, #00b877 100%)',
filter: undefined
},
{
title: '教研室待审批',
value: counts[1],
gradient: 'linear-gradient(135deg, #e6a23c 0%, #f8b04c 100%)',
filter: 0
},
{
title: '教研室已同意',
value: counts[2],
gradient: 'linear-gradient(135deg, #409eff 0%, #66b1ff 100%)',
filter: 1
},
{
title: '教研室已拒绝',
value: counts[3],
gradient: 'linear-gradient(135deg, #f56c6c 0%, #f78989 100%)',
filter: 3
}
]
},
handleStatClick(card) {
this.queryParams.jyspzzt = card.filter
this.handleQuery()
},
/* ---------- 新增申请 ---------- */
openAdd() {
this.addForm = {
nd: this.defaultNd || this.yearOptions[0],
sskcbbh: '',
sy: '',
rq: '',
jc: 1,
sqjybh: '',
xydrwbh: '',
kbh: '',
kcbbh: '',
xydmc: '',
yjsbh: '',
xjsbh: '',
ydsjap: '',
jxnr: '',
jxyd: '',
jxff: '',
jxbzbz: '',
ycxx: '',
bz: '',
roomList: [],
teacherList: [],
teamList: [],
supportList: []
}
this.lessonOptions = []
this.loadClassroomOptions()
this.$nextTick(() => this.$refs.addForm && this.$refs.addForm.clearValidate())
this.addVisible = true
},
handleAddYearChange() {
Object.assign(this.addForm, {
sskcbbh: '',
rq: '',
jc: 1,
sqjybh: '',
xydrwbh: '',
kbh: '',
kcbbh: '',
xydmc: '',
yjsbh: '',
xjsbh: '',
ydsjap: '',
jxnr: '',
jxyd: '',
jxff: '',
jxbzbz: '',
ycxx: '',
bz: '',
roomList: [],
teacherList: [],
teamList: [],
supportList: []
})
this.lessonOptions = []
},
loadClassroomOptions() {
this.classroomLoading = true
return listAllClassroom().then(response => {
const classrooms = Array.isArray(response.data) ? response.data : []
this.classroomOptions = classrooms
.filter(room => room.jsbh && !this.isDisabledValue(room.ty))
.sort((first, second) => String(first.jsbh).localeCompare(String(second.jsbh), 'zh-CN'))
}).catch(() => {
this.classroomOptions = []
this.$message.warning('教室列表加载失败,请稍后重试')
}).finally(() => {
this.classroomLoading = false
})
},
classroomOptionLabel(room) {
return room.jsmc ? room.jsbh + ' · ' + room.jsmc : room.jsbh
},
isDisabledValue(value) {
return value === true || value === 1 || value === '1' || value === 'true'
},
loadAdjustableLessons() {
if (!this.addForm.nd) {
this.$message.warning('请先选择年度')
return
}
this.lessonLoading = true
listAdjustableLessons({ nd: this.addForm.nd }).then(response => {
const data = response.data || []
this.lessonOptions = Array.isArray(data) ? data : (data.records || [])
this.lessonLoading = false
this.lessonDialogVisible = true
}).catch(() => {
this.lessonLoading = false
this.$message.warning('加载可供调课课次失败,请稍后重试')
})
},
pickLesson(row) {
const lessonNumber = row.sskcbbh || row.bh || ''
if (!lessonNumber) {
this.$message.warning('当前课次缺少课次编号,无法选择')
return
}
const currentRoomNumber = row.jsbh || row.yjsbh || row.xjsbh || ''
const roomList = this.normalizeRoomList(row.roomList, currentRoomNumber)
const teamList = this.normalizeTeamList(row.teamList, row.xydbh)
Object.assign(this.addForm, {
sskcbbh: lessonNumber,
rq: row.rq ? String(row.rq).substring(0, 10) : '',
jc: row.jc === null || row.jc === undefined ? 1 : Number(row.jc),
nd: row.nd || this.addForm.nd,
sqjybh: row.sqjybh || '',
xydrwbh: row.xydrwbh || '',
kbh: row.kbh || '',
kcbbh: row.kcbbh || '',
xydmc: row.xydmc || '',
yjsbh: row.yjsbh || currentRoomNumber,
xjsbh: row.xjsbh || currentRoomNumber,
ydsjap: row.rq ? this.fmtRqJc(row.rq, row.jc) : (row.ydsjap || ''),
jxnr: row.jxnr || '',
jxyd: row.jxyd || '',
jxff: row.jxff || '',
jxbzbz: row.jxbzbz || '',
ycxx: row.ycxx || '',
bz: row.bz || '',
roomList: roomList,
teacherList: this.normalizeTeacherList(row.teacherList),
teamList: teamList,
supportList: this.normalizeSupportList(row.supportList)
})
;['jysdh', 'kcmc', 'sqjyxm'].forEach(key => {
if (row[key] !== undefined && row[key] !== null) this.addForm[key] = row[key]
})
this.lessonDialogVisible = false
this.$nextTick(() => this.$refs.addForm && this.$refs.addForm.validateField('sskcbbh'))
this.$message.success('已选择课次:' + (row.kcmc || lessonNumber))
},
submitAdd() {
this.$refs.addForm.validate(valid => {
if (!valid) return
const payload = {
sskcbbh: this.addForm.sskcbbh,
sy: this.addForm.sy,
rq: this.addForm.rq + 'T00:00:00',
jc: this.addForm.jc,
nd: this.addForm.nd,
roomList: this.buildSubmitRoomList(),
teacherList: this.normalizeTeacherList(this.addForm.teacherList),
teamList: this.normalizeTeamList(this.addForm.teamList),
supportList: this.normalizeSupportList(this.addForm.supportList)
}
const optionalFields = [
'sqjybh', 'xydrwbh', 'kbh', 'kcbbh', 'xjsbh', 'jxnr', 'jxyd',
'jxff', 'jxbzbz', 'ycxx', 'bz'
]
optionalFields.forEach(key => {
if (this.addForm[key]) payload[key] = this.addForm[key]
})
this.addLoading = true
submitScheduleAdjust(payload).then(() => {
this.addLoading = false
this.addVisible = false
this.$message.success('申请提交成功')
this.loadList()
}).catch(() => {
this.addLoading = false
})
})
},
normalizeRoomList(roomList, fallbackRoomNumber) {
const normalizedList = (Array.isArray(roomList) ? roomList : [])
.filter(item => item && item.jsbh)
.map(item => ({
jsbh: item.jsbh,
bz: item.bz || ''
}))
if (normalizedList.length === 0 && fallbackRoomNumber) {
normalizedList.push({ jsbh: fallbackRoomNumber, bz: '' })
}
return normalizedList
},
normalizeTeacherList(teacherList) {
return (Array.isArray(teacherList) ? teacherList : [])
.filter(item => item && item.fzjybh)
.map(item => ({ fzjybh: item.fzjybh }))
},
normalizeTeamList(teamList, fallbackTeamNumber) {
const normalizedList = (Array.isArray(teamList) ? teamList : [])
.filter(item => item && item.xydbh)
.map(item => ({ xydbh: item.xydbh }))
if (normalizedList.length === 0 && fallbackTeamNumber) {
normalizedList.push({ xydbh: fallbackTeamNumber })
}
return normalizedList
},
normalizeSupportList(supportList) {
return (Array.isArray(supportList) ? supportList : [])
.filter(item => item && item.bztgmxbh)
.map(item => ({
bztgmxbh: item.bztgmxbh,
sl: item.sl === null || item.sl === undefined ? undefined : Number(item.sl)
}))
},
buildSubmitRoomList() {
if (!this.addForm.xjsbh) return []
const currentRoomList = this.normalizeRoomList(this.addForm.roomList)
if (this.addForm.xjsbh === this.addForm.yjsbh && currentRoomList.length > 0) {
return currentRoomList
}
const currentRoom = currentRoomList.find(item => item.jsbh === this.addForm.xjsbh)
return [{
jsbh: this.addForm.xjsbh,
bz: currentRoom ? currentRoom.bz : ''
}]
},
/* ---------- 审批 ---------- */
showAuditMenu(row) {
// 教员角色不展示审批入口;已拒绝状态不展示审批
if (this.isTeacher) return false
return row.sczt === 0 && (row.jyspzzt === 0 || row.jyspzzt === 2)
},
openAudit(row) {
this.auditForm = {
ssdksqbh: row.ssdksqbh,
spzt: 1,
fhyj: ''
}
this.auditVisible = true
},
submitAudit() {
const f = this.auditForm
if (f.spzt !== 1 && !f.fhyj) {
this.$message.warning('发回/拒绝时请填写审批意见')
return
}
const payload = {
ssdksqbh: f.ssdksqbh,
spzt: f.spzt,
fhyj: f.fhyj
}
this.auditLoading = true
auditByTeachingOffice(payload).then(() => {
this.auditLoading = false
this.auditVisible = false
this.$message.success('审批成功')
this.loadList()
}).catch(() => {
this.auditLoading = false
})
},
/* ---------- 撤销 ---------- */
handleCancel(row) {
this.$confirm('确认撤销该调课申请?', '提示', { type: 'warning' })
.then(() => {
cancelScheduleAdjust({ ssdksqbh: row.ssdksqbh }).then(() => {
this.$message.success('撤销成功')
this.loadList()
}).catch(() => {})
})
.catch(() => {})
},
/* ---------- 详情 ---------- */
openDetail(row) {
this.detailVisible = true
this.detailLoading = true
this.detailData = null
getScheduleAdjustDetail(row.ssdksqbh).then(response => {
this.detailData = response.data || {}
this.detailLoading = false
}).catch(() => {
this.detailData = null
this.detailLoading = false
})
}
}
}
</script>
<style scoped lang="scss">
.page-container {
padding: 16px;
.section-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 16px;
.section-title {
font-size: 16px;
font-weight: 700;
color: #303133;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
}
}
/* 统计卡片 */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 14px;
}
.stat-card {
display: flex;
flex-direction: column;
justify-content: center;
padding: 16px 20px;
border-radius: 6px;
color: #fff;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.stat-text {
font-size: 13px;
opacity: 0.92;
}
.stat-value {
margin-top: 8px;
font-size: 26px;
font-weight: 700;
line-height: 1;
}
}
/* 查询区域 */
.search-form {
margin-bottom: -16px;
::v-deep .el-form-item {
margin-bottom: 16px;
}
}
.compact-table {
width: 100%;
}
.danger-btn {
color: #f56c6c;
&:hover {
color: #f78989;
}
}
.ops-cell {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0 4px;
}
.form-tip {
font-size: 12px;
color: #909399;
line-height: 1.4;
margin-top: 4px;
}
.pick-lesson-btn {
margin-top: 6px;
width: 100%;
}
.sub-section {
margin-top: 16px;
.sub-title {
font-size: 14px;
font-weight: 700;
color: #303133;
margin-bottom: 8px;
}
}
}
@media (max-width: 992px) {
.page-container .stat-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
@@ -0,0 +1,423 @@
<template>
<div class="app-container conflict-page">
<!-- 冲突检查区域 -->
<div class="section-card">
<div class="section-header">
<div>
<div class="section-title">教学资源冲突检查</div>
<div class="section-desc">当前检查年度:{{ nd ? nd + ' 年' : '未选择' }},可单独检查或执行全部检查</div>
</div>
<div class="header-actions">
<el-form inline class="toolbar-form" @submit.native.prevent>
<el-form-item label="年度" class="year-item">
<el-select
v-model="nd"
placeholder="请选择年度"
filterable
style="width: 130px"
@change="handleNdChange"
>
<el-option v-for="y in yearOptions" :key="y" :label="y + ' 年'" :value="y" />
</el-select>
</el-form-item>
</el-form>
<el-button size="mini" :disabled="loading" @click="handleReset">重置</el-button>
<el-button type="primary" size="mini" :loading="loading" @click="handleCheckAll">执行全部检查</el-button>
</div>
</div>
<!-- 检查项卡片网格 -->
<div class="check-grid">
<div
v-for="item in conflictItems"
:key="item.key"
class="check-item-card"
:class="{ active: activeKey === item.key }"
@click="selectCard(item)"
>
<div class="check-item-header">
<span class="check-item-name">{{ item.label }}</span>
<el-tag :type="getStatus(item).type" size="mini">{{ getStatus(item).text }}</el-tag>
</div>
<div class="check-item-desc">{{ item.desc }}</div>
<div class="check-item-footer">
<span class="conflict-count">
冲突数:
<b :class="item.count > 0 ? 'danger-text' : 'normal-text'">{{ item.count }}</b>
</span>
<el-button type="primary" plain size="mini" :disabled="loading" @click.stop="handleCheck(item)">检查</el-button>
</div>
</div>
</div>
</div>
<!-- 冲突明细区域 -->
<div class="section-card">
<div class="section-header detail-header">
<div class="section-title">
冲突明细
<span v-if="activeKey" class="active-label">— {{ activeLabel }}</span>
</div>
<span v-if="activeKey" class="detail-count">共 {{ detailTotal }} 条记录</span>
</div>
<template v-if="activeKey">
<el-table v-loading="detailLoading" :data="activeDetails" border stripe size="mini" max-height="500">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column label="冲突号" width="90" align="center" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.conflictNo || '-' }}</template>
</el-table-column>
<el-table-column prop="courseNames" label="课程" min-width="150" align="center" show-overflow-tooltip />
<el-table-column label="日期" width="100" align="center">
<template slot-scope="scope">{{ fmtDate(scope.row.rq) }}</template>
</el-table-column>
<el-table-column prop="jc" label="节次" width="70" align="center" />
<el-table-column prop="xydNames" label="班次" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="teacherNames" label="教员" min-width="120" align="center" show-overflow-tooltip />
<el-table-column prop="classroomNames" label="场地" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="responsibleDept" label="责任单位" min-width="110" align="center" show-overflow-tooltip />
</el-table>
<el-pagination
v-if="detailTotal > 0"
class="detail-pagination"
background
layout="total, prev, pager, next"
:current-page="detailQuery.pageNum"
:page-size="detailQuery.pageSize"
:total="detailTotal"
@current-change="handlePageChange"
/>
<el-empty v-if="detailTotal === 0 && !detailLoading" description="暂无冲突明细" />
</template>
<el-empty v-else v-show="!loading" description="点击上方卡片「检查」按钮,在此查看冲突明细" />
</div>
</div>
</template>
<script>
import { checkConflict, checkAllConflict, resetConflict, getConflictDetails } from '@/api/teachBusiness/timetableConflict'
import { listAllSemester } from '@/api/teachBusiness/semester'
// 四类检查卡片定义(标题/描述与后端 TimetableConflictDimension 枚举一致,作为页面布局常量;
// 检查状态与冲突数完全来自后端接口)
const CARD_DEFS = [
{ key: 'TEAM_CONFLICT', label: '教学班时间冲突检查', desc: '同一教学班次在同一时间段被安排多门课程' },
{ key: 'ELECTIVE_REQUIRED_CONFLICT', label: '教学班必修与选修时间冲突检查', desc: '教学班次必修课与选修课时间重叠' },
{ key: 'TEACHER_CONFLICT', label: '教员时间冲突检查', desc: '同一教员在同一时间段被安排多门课程' },
{ key: 'CLASSROOM_CONFLICT', label: '教室时间冲突检查', desc: '同一教室在同一时间段被多门课程占用' }
]
export default {
name: 'TimetableConflict',
data() {
return {
loading: false,
detailLoading: false,
// 年度(数据来自 /semester/all,真实后端数据)
nd: undefined,
yearOptions: [],
conflictItems: CARD_DEFS.map(c => ({ ...c, count: 0, checked: false })),
activeKey: '',
activeDetails: [],
detailTotal: 0,
detailQuery: { pageNum: 1, pageSize: 20 }
}
},
computed: {
activeLabel() {
const item = this.conflictItems.find(i => i.key === this.activeKey)
return item ? item.label : ''
}
},
created() {
this.loadYearOptions()
},
methods: {
/* ---------- 年度下拉(数据来自 /semester/all) ---------- */
loadYearOptions() {
return listAllSemester().then(response => {
const list = response.data || []
// 去重并按年度倒序
const map = {}
list.forEach(item => {
if (item.nd !== null && item.nd !== undefined && item.nd !== '') map[item.nd] = item
})
const arr = Object.keys(map).sort((a, b) => String(b).localeCompare(String(a)))
this.yearOptions = arr
// 默认年度:优先当前学期(dqxq=true),否则取最新年度
const current = list.find(item => item.dqxq === true)
this.nd = (current && current.nd) || arr[0]
return arr
}).catch(() => {
this.yearOptions = []
this.nd = undefined
return []
})
},
fmtDate(rq) {
return (rq || '').substring(0, 10)
},
getStatus(item) {
if (!item.checked) return { type: 'info', text: '未检查' }
return item.count > 0
? { type: 'danger', text: '存在 ' + item.count + ' 处冲突' }
: { type: 'success', text: '无冲突' }
},
/* 用后端返回的单卡片结果同步对应卡片状态 */
applyCard(card) {
if (!card) return
const item = this.conflictItems.find(i => i.key === card.dimensionCode)
if (!item) return
item.checked = card.checked
item.count = card.conflictCount
},
applySummary(summary) {
if (!summary || !summary.cards) return
summary.cards.forEach(card => this.applyCard(card))
},
/* ---------- 单个检查 ---------- */
handleCheck(item) {
if (!this.nd) {
this.$message.warning('请先选择年度')
return
}
this.loading = true
checkConflict({ nd: this.nd, dimensionCode: item.key }).then(response => {
this.applyCard(response.data)
this.activeKey = item.key
this.detailQuery.pageNum = 1
this.loadDetails()
if (item.count > 0) {
this.$message.warning('「' + item.label + '」检查完成,发现 ' + item.count + ' 处冲突')
} else {
this.$message.success('「' + item.label + '」检查完成,未发现冲突')
}
}).finally(() => {
this.loading = false
})
},
/* ---------- 全部检查 ---------- */
handleCheckAll() {
if (!this.nd) {
this.$message.warning('请先选择年度')
return
}
this.loading = true
checkAllConflict({ nd: this.nd }).then(response => {
const summary = response.data
this.applySummary(summary)
const total = summary ? summary.totalConflictCount : 0
this.$message.success('全部检查完成,共发现 ' + total + ' 处冲突')
const firstConflict = this.conflictItems.find(i => i.checked && i.count > 0)
const firstChecked = this.conflictItems.find(i => i.checked)
this.activeKey = firstConflict ? firstConflict.key : (firstChecked ? firstChecked.key : '')
this.detailQuery.pageNum = 1
if (this.activeKey) {
this.loadDetails()
} else {
this.activeDetails = []
this.detailTotal = 0
}
}).finally(() => {
this.loading = false
})
},
/* ---------- 重置 ---------- */
handleReset() {
if (!this.nd) {
this.$message.warning('请先选择年度')
return
}
resetConflict({ nd: this.nd }).then(response => {
this.applySummary(response.data)
this.activeKey = ''
this.activeDetails = []
this.detailTotal = 0
this.$message.success('检查结果已重置')
})
},
/* 点击卡片:仅已检查的卡片可查看其明细 */
selectCard(item) {
if (!item.checked) return
this.activeKey = item.key
this.detailQuery.pageNum = 1
this.loadDetails()
},
/* ---------- 冲突明细 ---------- */
loadDetails() {
if (!this.activeKey) {
this.activeDetails = []
this.detailTotal = 0
return
}
this.detailLoading = true
getConflictDetails({
nd: this.nd,
dimensionCode: this.activeKey,
pageNum: this.detailQuery.pageNum,
pageSize: this.detailQuery.pageSize
}).then(response => {
const page = response.data || {}
this.activeDetails = page.records || []
this.detailTotal = page.total || 0
}).catch(() => {
this.activeDetails = []
this.detailTotal = 0
}).finally(() => {
this.detailLoading = false
})
},
handlePageChange(page) {
this.detailQuery.pageNum = page
this.loadDetails()
},
/* 切换年度:该年度检查状态未知,重置为未检查 */
handleNdChange() {
this.conflictItems.forEach(i => {
i.checked = false
i.count = 0
})
this.activeKey = ''
this.activeDetails = []
this.detailTotal = 0
this.detailQuery.pageNum = 1
}
}
}
</script>
<style scoped lang="scss">
.conflict-page {
.section-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 16px;
.section-title {
font-size: 16px;
font-weight: 700;
color: #303133;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.header-actions {
display: flex;
align-items: center;
gap: 12px;
.toolbar-form {
margin-right: 4px;
.year-item {
margin-bottom: 0;
}
}
}
}
.section-desc {
margin-top: 8px;
font-size: 13px;
color: #909399;
}
.detail-header {
margin-bottom: 12px;
}
}
/* 检查项卡片网格 */
.check-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.check-item-card {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 14px 16px;
transition: box-shadow 0.2s ease;
cursor: pointer;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&.active {
border-color: var(--edu-green-primary, #00875a);
box-shadow: 0 0 0 2px rgba(0, 135, 90, 0.15);
}
.check-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.check-item-name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
}
.check-item-desc {
margin-top: 8px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.check-item-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
.conflict-count {
font-size: 13px;
color: #606266;
b {
font-size: 18px;
}
}
}
}
.danger-text {
color: #f56c6c;
font-weight: 700;
}
.normal-text {
color: #67c23a;
font-weight: 700;
}
.active-label {
font-size: 14px;
font-weight: 400;
color: var(--edu-green-primary, #00875a);
}
.detail-count {
font-size: 13px;
color: #909399;
}
.detail-pagination {
margin-top: 12px;
text-align: right;
}
}
</style>
@@ -0,0 +1,776 @@
<template>
<div class="app-container training-plan-page">
<!-- ==================== 1. 查询条件区域 ==================== -->
<el-card shadow="never" class="search-card">
<el-form :model="searchForm" label-width="100px" class="search-form" @submit.native.prevent>
<el-row :gutter="24">
<el-col :xs="24" :md="8">
<el-form-item label="专业名称">
<el-input v-model="searchForm.zymc" placeholder="请输入专业名称" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="专业代码">
<el-input v-model="searchForm.zydm" placeholder="请输入专业代码" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="培训类型">
<el-select
v-model="searchForm.pxlx"
placeholder="请选择培训类型"
clearable
filterable
class="w-full"
>
<el-option
v-for="item in trainingTypeOptions"
:key="item.dictCode || item.dictValue"
:label="item.dictLabel"
:value="item.dictLabel"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="培训层次">
<el-select
v-model="searchForm.pxcc"
placeholder="请选择培训层次"
clearable
filterable
class="w-full"
>
<el-option
v-for="item in trainingLevelOptions"
:key="item.dictCode || item.dictValue"
:label="item.dictLabel"
:value="item.dictLabel"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<div class="search-actions">
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
</div>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- ==================== 2. 操作与上传区域 ==================== -->
<el-card shadow="never" class="action-card">
<div class="action-row">
<el-button type="primary" icon="el-icon-plus" @click="handleAdd">新建</el-button>
<el-button icon="el-icon-download" @click="handleDownload">下载</el-button>
</div>
<div class="action-row">
<el-button icon="el-icon-download" @click="handleTemplateDownload">【人才培养方案目录模板】下载</el-button>
</div>
<div class="upload-row">
<div class="upload-left">
<el-button icon="el-icon-folder-opened" @click="handleChooseFile">选择文件</el-button>
<span class="file-name" :class="{ 'has-file': selectedFile }">
<template v-if="selectedFile">{{ fileName }}</template>
<template v-else>请选择要上传的文件</template>
</span>
<input ref="fileInputRef" type="file" style="display: none" @change="handleFileChange" />
</div>
<div class="upload-right">
<el-button type="primary" icon="el-icon-upload2" :disabled="!selectedFile" @click="handleUpload">上传数据</el-button>
</div>
</div>
</el-card>
<!-- ==================== 3. 数据表格区域 ==================== -->
<el-card shadow="never" class="table-card">
<div class="list-header">
<div class="list-title">人才培养方案列表</div>
</div>
<el-table v-loading="loading" :data="tableData" border stripe highlight-current-row>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="zydh" label="专业代号" width="120" align="center" show-overflow-tooltip />
<el-table-column prop="zymc" label="专业名称" width="140" align="center" show-overflow-tooltip />
<el-table-column prop="zyfx" label="专业方向" width="120" align="center" show-overflow-tooltip />
<el-table-column prop="zydm" label="专业代码" width="110" align="center" show-overflow-tooltip />
<el-table-column prop="pxlx" label="培训类型" width="110" align="center" show-overflow-tooltip />
<el-table-column prop="pxcc" label="培训层次" width="120" align="center" show-overflow-tooltip />
<el-table-column prop="pxlx2" label="培训类型2" width="110" align="center" show-overflow-tooltip />
<el-table-column prop="xylb" label="学员类别" width="100" align="center" show-overflow-tooltip />
<el-table-column prop="xnz" label="学年制" width="80" align="center" show-overflow-tooltip />
<el-table-column prop="xqs" label="学期数" width="80" align="center" />
<el-table-column prop="zybb" label="专业版本" width="100" align="center" show-overflow-tooltip />
<el-table-column label="主干专业" width="90" align="center">
<template slot-scope="scope">{{ fmtYesNo(scope.row.zgzy) }}</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="isTrue(scope.row.ty) ? 'danger' : 'success'" size="mini">
{{ isTrue(scope.row.ty) ? '停用' : '启用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="启用时间" width="150" align="center">
<template slot-scope="scope">{{ fmtDateTime(scope.row.qysj) }}</template>
</el-table-column>
<el-table-column label="停用时间" width="150" align="center">
<template slot-scope="scope">{{ fmtDateTime(scope.row.tysj) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-view" @click="handleDetail(scope.row)">详情</el-button>
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="text" size="mini" icon="el-icon-circle-close" class="danger-text-btn"
:disabled="isTrue(scope.row.ty)" @click="handleDisable(scope.row)">停用</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="pageNum"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog :title="dialog.title" :visible.sync="dialog.visible" width="860px" append-to-body
:close-on-click-modal="false">
<el-form ref="trainingForm" :model="dialog.form" :rules="rules" label-width="130px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="专业名称" prop="zymc">
<el-input v-model="dialog.form.zymc" placeholder="请输入专业名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="专业方向">
<el-select
v-model="dialog.form.zyfx"
placeholder="请选择专业方向"
clearable
filterable
class="training-dict-select"
>
<el-option
v-for="item in majorDirectionOptions"
:key="item.dictCode || item.dictValue"
:label="item.dictLabel"
:value="item.dictLabel"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="培训层次" prop="pxcc">
<el-select
v-model="dialog.form.pxcc"
placeholder="请选择培训层次"
filterable
class="training-dict-select"
>
<el-option
v-for="item in trainingLevelOptions"
:key="item.dictCode || item.dictValue"
:label="item.dictLabel"
:value="item.dictLabel"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="培训类型" prop="pxlx">
<el-select
v-model="dialog.form.pxlx"
placeholder="请选择培训类型"
filterable
class="training-dict-select"
>
<el-option
v-for="item in trainingTypeOptions"
:key="item.dictCode || item.dictValue"
:label="item.dictLabel"
:value="item.dictLabel"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="培训类型2">
<el-input v-model="dialog.form.pxlx2" placeholder="请输入培训类型2" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学员类别">
<el-select
v-model="dialog.form.xylb"
placeholder="请选择学员类别"
clearable
filterable
class="training-dict-select"
>
<el-option
v-for="item in studentCategoryOptions"
:key="item.dictCode || item.dictValue"
:label="item.dictLabel"
:value="item.dictLabel"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学年制">
<el-input v-model="dialog.form.xnz" placeholder="请输入学年制" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学期数">
<el-input-number v-model="dialog.form.xqs" :min="1" :max="20" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="专业版本">
<el-input v-model="dialog.form.zybb" placeholder="请输入专业版本" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="自定义分类">
<el-input v-model="dialog.form.zdyfl" placeholder="请输入自定义分类" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="教学管理机构">
<el-select
v-model="dialog.form.jxgljgbh"
placeholder="请选择教学管理机构"
clearable
filterable
class="training-dict-select"
>
<el-option
v-for="item in teachingOrganizationOptions"
:key="item.dictCode || item.dictValue"
:label="item.dictLabel"
:value="item.dictValue"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规范名称">
<el-input v-model="dialog.form.gfmc" placeholder="请输入规范名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="专业规范">
<el-input v-model="dialog.form.zygf" placeholder="请输入专业规范" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="简称">
<el-input v-model="dialog.form.jc" placeholder="请输入简称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主干专业">
<el-radio-group v-model="dialog.form.zgzy">
<el-radio :label="true">是</el-radio>
<el-radio :label="false">否</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="专业备注">
<el-input v-model="dialog.form.zybz" placeholder="请输入专业备注" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="培养目标">
<el-input v-model="dialog.form.pymb" type="textarea" :rows="3" placeholder="请输入培养目标" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="dialog.visible = false">取 消</el-button>
<el-button type="primary" :loading="dialog.submitting" @click="submitDialog">确 定</el-button>
</div>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog title="人才培养方案详情" :visible.sync="detail.visible" width="760px" append-to-body
:close-on-click-modal="false">
<div v-loading="detail.loading" class="detail-body">
<el-descriptions :column="2" border>
<el-descriptions-item label="专业代号">{{ fmtVal(detail.data.zydh) }}</el-descriptions-item>
<el-descriptions-item label="专业名称">{{ fmtVal(detail.data.zymc) }}</el-descriptions-item>
<el-descriptions-item label="专业代码">{{ fmtVal(detail.data.zydm) }}</el-descriptions-item>
<el-descriptions-item label="专业方向">{{ fmtVal(detail.data.zyfx) }}</el-descriptions-item>
<el-descriptions-item label="培训类型">{{ fmtVal(detail.data.pxlx) }}</el-descriptions-item>
<el-descriptions-item label="培训层次">{{ fmtVal(detail.data.pxcc) }}</el-descriptions-item>
<el-descriptions-item label="培训类型2">{{ fmtVal(detail.data.pxlx2) }}</el-descriptions-item>
<el-descriptions-item label="学员类别">{{ fmtVal(detail.data.xylb) }}</el-descriptions-item>
<el-descriptions-item label="学年制">{{ fmtVal(detail.data.xnz) }}</el-descriptions-item>
<el-descriptions-item label="学期数">{{ fmtVal(detail.data.xqs) }}</el-descriptions-item>
<el-descriptions-item label="专业版本">{{ fmtVal(detail.data.zybb) }}</el-descriptions-item>
<el-descriptions-item label="主干专业">{{ fmtYesNo(detail.data.zgzy) }}</el-descriptions-item>
<el-descriptions-item label="自定义分类">{{ fmtVal(detail.data.zdyfl) }}</el-descriptions-item>
<el-descriptions-item label="教学管理机构">{{ fmtVal(detail.data.jxgljgbh) }}</el-descriptions-item>
<el-descriptions-item label="规范名称">{{ fmtVal(detail.data.gfmc) }}</el-descriptions-item>
<el-descriptions-item label="专业规范">{{ fmtVal(detail.data.zygf) }}</el-descriptions-item>
<el-descriptions-item label="简称">{{ fmtVal(detail.data.jc) }}</el-descriptions-item>
<el-descriptions-item label="专业标识号">{{ fmtVal(detail.data.zybsh) }}</el-descriptions-item>
<el-descriptions-item label="学科专业信息标识号">{{ fmtVal(detail.data.xkzyxxbsh) }}</el-descriptions-item>
<el-descriptions-item label="教学大纲编号">{{ fmtVal(detail.data.jxdgbh) }}</el-descriptions-item>
<el-descriptions-item label="人培编号">{{ fmtVal(detail.data.rpbh) }}</el-descriptions-item>
<el-descriptions-item label="节次类别">{{ fmtVal(detail.data.jclb) }}</el-descriptions-item>
<el-descriptions-item label="系统模式">{{ fmtVal(detail.data.xtms) }}</el-descriptions-item>
<el-descriptions-item label="启用时间">{{ fmtDateTime(detail.data.qysj) }}</el-descriptions-item>
<el-descriptions-item label="停用时间">{{ fmtDateTime(detail.data.tysj) }}</el-descriptions-item>
<el-descriptions-item label="专业备注" :span="2">{{ fmtVal(detail.data.zybz) }}</el-descriptions-item>
<el-descriptions-item label="培养目标" :span="2">{{ fmtVal(detail.data.pymb) }}</el-descriptions-item>
</el-descriptions>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detail.visible = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { saveAs } from 'file-saver'
import {
addTraining,
disableTraining,
updateTraining,
getTraining,
listTraining,
exportTrainingProgram,
downloadTrainingProgramTemplate,
importTrainingProgram
} from '@/api/teachBusiness/training'
import { getDicts } from '@/api/system/dict/data'
import { optionselect } from '@/api/system/dict/type'
const TRAINING_TYPE_DICT_CODE = 'train_type'
const TRAINING_LEVEL_DICT_CODE = 'train_level'
export default {
name: 'TrainingPlan',
data() {
return {
loading: false,
// 查询条件(仅传后端 ZYBMapper 支持的字段)
searchForm: {
zymc: '',
zydm: '',
pxlx: '',
pxcc: ''
},
trainingTypeOptions: [],
trainingLevelOptions: [],
majorDirectionOptions: [],
studentCategoryOptions: [],
teachingOrganizationOptions: [],
// 列表
tableData: [],
total: 0,
pageNum: 1,
pageSize: 20,
// 上传
selectedFile: null,
fileName: '',
// 新增/编辑弹窗
dialog: {
visible: false,
title: '',
isEdit: false,
submitting: false,
form: this.createEmptyForm()
},
// 详情弹窗
detail: {
visible: false,
loading: false,
data: {}
},
rules: {
zymc: [{ required: true, message: '请输入专业名称', trigger: 'blur' }],
pxcc: [{ required: true, message: '请选择培训层次', trigger: 'change' }],
pxlx: [{ required: true, message: '请选择培训类型', trigger: 'change' }]
}
}
},
created() {
this.loadTrainingDictionaries()
this.fetchList()
},
methods: {
/**
* 从系统字典加载表单下拉选项;文本业务字段提交标签,机构字段提交编号。
*/
async loadTrainingDictionaries() {
const emptyResponse = { data: [] }
const [typeResponse, levelResponse, dictTypeResponse] = await Promise.all([
getDicts(TRAINING_TYPE_DICT_CODE).catch(() => emptyResponse),
getDicts(TRAINING_LEVEL_DICT_CODE).catch(() => emptyResponse),
optionselect().catch(() => emptyResponse)
])
this.trainingTypeOptions = typeResponse.data || []
this.trainingLevelOptions = levelResponse.data || []
const dictTypes = dictTypeResponse.data || []
const [directionOptions, categoryOptions, organizationOptions] = await Promise.all([
this.loadNamedDictionary(dictTypes, '专业方向'),
this.loadNamedDictionary(dictTypes, '学员类别'),
this.loadNamedDictionary(dictTypes, '教学管理机构')
])
this.majorDirectionOptions = directionOptions
this.studentCategoryOptions = categoryOptions
this.teachingOrganizationOptions = organizationOptions
this.mergeExistingSelectOptions()
},
async loadNamedDictionary(dictTypes, dictName) {
const dictType = dictTypes.find(item => item.dictName === dictName) ||
dictTypes.find(item => String(item.dictName || '').includes(dictName))
if (!dictType || !dictType.dictType) {
return []
}
try {
const response = await getDicts(dictType.dictType)
return response.data || []
} catch (error) {
return []
}
},
mergeExistingSelectOptions() {
this.majorDirectionOptions = this.mergeTextOptions(this.majorDirectionOptions, 'zyfx', true)
this.studentCategoryOptions = this.mergeTextOptions(this.studentCategoryOptions, 'xylb', true)
this.teachingOrganizationOptions = this.mergeTextOptions(
this.teachingOrganizationOptions,
'jxgljgbh',
false
)
},
mergeTextOptions(dictionaryOptions, fieldName, usesLabel) {
const options = dictionaryOptions.slice()
const existingValues = new Set(options.map(item => String(usesLabel ? item.dictLabel : item.dictValue)))
this.tableData.forEach(row => {
const value = row[fieldName]
if (value === '' || value === null || value === undefined || existingValues.has(String(value))) {
return
}
options.push({
dictLabel: String(value),
dictValue: String(value)
})
existingValues.add(String(value))
})
return options
},
/* ---------- 列表加载 ---------- */
fetchList() {
this.loading = true
const params = {
pageNum: this.pageNum,
pageSize: this.pageSize
}
Object.keys(this.searchForm).forEach(key => {
const value = this.searchForm[key]
if (value !== '' && value !== null && value !== undefined) {
params[key] = value
}
})
listTraining(params).then(response => {
const data = response.data || {}
this.tableData = data.records || []
this.total = data.total || 0
this.mergeExistingSelectOptions()
}).catch(() => {
this.tableData = []
this.total = 0
}).finally(() => {
this.loading = false
})
},
/* ---------- 查询 / 重置 ---------- */
handleQuery() {
this.pageNum = 1
this.fetchList()
},
handleReset() {
this.searchForm = { zymc: '', zydm: '', pxlx: '', pxcc: '' }
this.pageNum = 1
this.fetchList()
},
/* ---------- 分页 ---------- */
handleSizeChange(size) {
this.pageSize = size
this.pageNum = 1
this.fetchList()
},
handlePageChange(page) {
this.pageNum = page
this.fetchList()
},
/* ---------- 新增 / 编辑 ---------- */
handleAdd() {
this.dialog.title = '新建人才培养方案'
this.dialog.isEdit = false
this.dialog.form = this.createEmptyForm()
this.dialog.visible = true
this.$nextTick(() => {
if (this.$refs.trainingForm) this.$refs.trainingForm.clearValidate()
})
},
handleEdit(row) {
this.dialog.title = '编辑人才培养方案'
this.dialog.isEdit = true
this.dialog.form = Object.assign({}, this.createEmptyForm(), row)
this.dialog.visible = true
this.$nextTick(() => {
if (this.$refs.trainingForm) this.$refs.trainingForm.clearValidate()
})
},
submitDialog() {
this.$refs.trainingForm.validate(valid => {
if (!valid) return
this.dialog.submitting = true
const payload = this.cleanPayload(this.dialog.form)
const isEdit = this.dialog.isEdit
const req = isEdit ? updateTraining(payload) : addTraining(payload)
req.then(() => {
this.$message.success(isEdit ? '修改成功' : '新增成功')
this.dialog.visible = false
this.fetchList()
}).catch(() => {}).finally(() => {
this.dialog.submitting = false
})
})
},
/* ---------- 详情 ---------- */
handleDetail(row) {
this.detail.visible = true
this.detail.loading = true
this.detail.data = {}
getTraining(row.zydh).then(response => {
this.detail.data = response.data || {}
}).catch(() => {
this.detail.data = {}
}).finally(() => {
this.detail.loading = false
})
},
/* ---------- 停用(逻辑删除) ---------- */
handleDisable(row) {
this.$confirm('确定停用人才培养方案「' + (row.zymc || row.zydh) + '」吗?停用后列表不再展示。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
return disableTraining(row.zydh)
}).then(() => {
this.$message.success('停用成功')
this.fetchList()
}).catch(() => {})
},
/* ---------- 下载 / 模板下载 / 上传 ---------- */
handleDownload() {
exportTrainingProgram().then(blob => {
saveAs(blob, '人才培养方案课程数据文件.xlsx')
this.$message.success('导出成功')
}).catch(() => {})
},
handleTemplateDownload() {
downloadTrainingProgramTemplate().then(blob => {
saveAs(blob, '人才培养方案课程数据文件模板.xls')
this.$message.success('模板下载成功')
}).catch(() => {})
},
handleChooseFile() {
this.$refs.fileInputRef && this.$refs.fileInputRef.click()
},
handleFileChange(e) {
const file = e.target.files && e.target.files[0]
this.selectedFile = file || null
this.fileName = file ? file.name : ''
},
handleUpload() {
if (!this.selectedFile) {
this.$message.warning('请先选择文件')
return
}
importTrainingProgram(this.selectedFile).then(response => {
const count = (response && response.data) || 0
this.$message.success(`导入成功,共导入 ${count} 条`)
this.selectedFile = null
this.fileName = ''
if (this.$refs.fileInputRef) this.$refs.fileInputRef.value = ''
this.fetchList()
}).catch(() => {})
},
/* ---------- 工具 ---------- */
createEmptyForm() {
return {
zydh: '',
zymc: '',
zyfx: '',
zydm: '',
pxcc: '',
pxlx: '',
pxlx2: '',
xylb: '',
xnz: '',
xqs: null,
zybb: '',
zdyfl: '',
jxgljgbh: '',
gfmc: '',
zygf: '',
jc: '',
zgzy: false,
zybz: '',
pymb: ''
}
},
/** 移除空值(''/null/undefined),保留 0/false 等有效值 */
cleanPayload(obj) {
const payload = {}
Object.keys(obj).forEach(key => {
const value = obj[key]
if (value !== '' && value !== null && value !== undefined) {
payload[key] = value
}
})
return payload
},
isTrue(val) {
return val === true || val === 1 || val === '1' || val === 'true'
},
fmtYesNo(val) {
return this.isTrue(val) ? '是' : '否'
},
fmtDateTime(val) {
const s = (val || '').substring(0, 10)
return s || '-'
},
fmtVal(val) {
return val === '' || val === null || val === undefined ? '-' : val
}
}
}
</script>
<style scoped lang="scss">
.app-container {
padding: 20px;
}
.training-plan-page {
.search-card {
margin-bottom: 16px;
.search-form {
.w-full {
width: 100%;
}
.search-actions {
padding-top: 4px;
}
}
}
.action-card {
margin-bottom: 16px;
.action-row {
margin-bottom: 12px;
&:last-of-type {
margin-bottom: 0;
}
}
.upload-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px;
background: #fafafa;
border: 1px dashed #dcdfe6;
border-radius: 4px;
.upload-left {
display: flex;
align-items: center;
gap: 12px;
.file-name {
font-size: 13px;
color: #909399;
&.has-file {
color: #409eff;
}
}
}
}
}
.table-card {
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.list-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.pagination {
margin-top: 16px;
text-align: right;
}
}
.detail-body {
min-height: 60px;
}
}
.danger-text-btn {
color: #f56c6c;
&:hover {
color: #f78989;
}
}
.training-dict-select {
width: 100%;
}
</style>
@@ -0,0 +1,495 @@
<template>
<div class="app-container venue-page">
<!-- 查询条件 -->
<el-card shadow="never" class="search-card">
<el-form :model="searchForm" label-width="100px" class="search-form" @submit.native.prevent>
<el-row :gutter="24">
<el-col :xs="24" :md="8">
<el-form-item label="教室编号">
<el-input v-model="searchForm.jsbh" placeholder="请输入教室编号" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="教室名称">
<el-input v-model="searchForm.jsmc" placeholder="请输入教室名称" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="教学楼代号">
<el-input v-model="searchForm.jxldh" placeholder="请输入教学楼代号" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="教学场地类型">
<el-select v-model="searchForm.jxcdlx" placeholder="请选择场地类型" clearable filterable allow-create
default-first-option class="w-full">
<el-option v-for="item in jxcdlxOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<el-form-item label="停用">
<el-radio-group v-model="searchForm.ty">
<el-radio :label="false">否</el-radio>
<el-radio :label="true">是</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8">
<div class="search-actions">
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
</div>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 数据列表 -->
<el-card shadow="never" class="table-card">
<div class="list-header">
<div class="list-title">教室列表</div>
<div class="list-actions">
<el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button>
<el-button icon="el-icon-download" @click="handleDownloadTemplate">【教学场地管理模板】下载</el-button>
</div>
</div>
<el-table v-loading="loading" :data="tableData" border stripe highlight-current-row>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="jsbh" label="教室编号" width="130" align="center" show-overflow-tooltip />
<el-table-column prop="jsmc" label="教室名称" width="140" align="center" show-overflow-tooltip />
<el-table-column prop="jxldh" label="教学楼代号" width="150" align="center" show-overflow-tooltip />
<el-table-column prop="jxcdlx" label="教学场地类型" width="150" align="center" show-overflow-tooltip />
<el-table-column prop="jc" label="简称" width="100" align="center" show-overflow-tooltip />
<el-table-column prop="rnrs" label="容纳人数" width="90" align="center" />
<el-table-column prop="mj" label="面积" width="90" align="center" />
<el-table-column prop="ksrnrs" label="考试容纳人数" width="110" align="center" />
<el-table-column prop="ewyxkbs" label="额外可开班数" width="100" align="center" />
<el-table-column label="虚实类型" width="90" align="center">
<template slot-scope="scope">{{ fmtXslx(scope.row.xslx) }}</template>
</el-table-column>
<el-table-column label="停用" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="isTrue(scope.row.ty) ? 'danger' : 'success'" size="mini">
{{ isTrue(scope.row.ty) ? '停用' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sssb" label="设施设备" width="140" show-overflow-tooltip header-align="center" />
<el-table-column label="操作" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" class="danger-text-btn" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
background
layout="total, sizes, prev, pager, next"
:current-page="pageNum"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog :title="dialog.title" :visible.sync="dialog.visible" width="780px" append-to-body
:close-on-click-modal="false">
<el-form ref="classroomForm" :model="dialog.form" :rules="rules" label-width="110px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="教室名称" prop="jsmc">
<el-input v-model="dialog.form.jsmc" placeholder="请输入教室名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="教学楼代号">
<el-input v-model="dialog.form.jxldh" placeholder="请输入教学楼代号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="教学场地类型">
<el-select v-model="dialog.form.jxcdlx" placeholder="请选择场地类型" clearable filterable allow-create
default-first-option class="w-full">
<el-option v-for="item in jxcdlxOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="简称">
<el-input v-model="dialog.form.jc" placeholder="请输入简称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="容纳人数">
<el-input-number v-model="dialog.form.rnrs" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="面积">
<div class="number-unit-field">
<el-input-number
v-model="dialog.form.mj"
:min="0"
:precision="1"
controls-position="right"
/>
<span class="number-unit">㎡</span>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="考试容纳人数">
<el-input-number v-model="dialog.form.ksrnrs" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="额外可开班数">
<el-input-number v-model="dialog.form.ewyxkbs" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="拼音">
<el-input v-model="dialog.form.py" placeholder="请输入拼音" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="虚实类型">
<el-radio-group v-model="dialog.form.xslx">
<el-radio :label="true">实教</el-radio>
<el-radio :label="false">非实教</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="停用">
<el-radio-group v-model="dialog.form.ty">
<el-radio :label="false">正常</el-radio>
<el-radio :label="true">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设施设备">
<el-input v-model="dialog.form.sssb" placeholder="请输入设施设备" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="dialog.form.bz" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="dialog.visible = false">取 消</el-button>
<el-button type="primary" :loading="dialog.submitting" @click="submitDialog">确 定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { saveAs } from 'file-saver'
import {
addClassroom,
deleteClassroom,
updateClassroom,
listClassroom,
listAllClassroom,
downloadClassroomTemplate
} from '@/api/teachBusiness/classroom'
export default {
name: 'Venue',
data() {
return {
loading: false,
searchForm: {
jsbh: '',
jsmc: '',
jxldh: '',
jxcdlx: '',
ty: false
},
tableData: [],
jxcdlxOptions: [],
total: 0,
pageNum: 1,
pageSize: 20,
dialog: {
visible: false,
title: '',
submitting: false,
form: this.createEmptyForm()
},
rules: {
jsmc: [{ required: true, message: '请输入教室名称', trigger: 'blur' }]
}
}
},
created() {
this.loadVenueTypeOptions()
this.fetchList()
},
methods: {
/**
* 独立加载全部场地类型,避免查询结果收窄后下拉候选项同步丢失。
*/
loadVenueTypeOptions() {
return listAllClassroom().then(response => {
this.jxcdlxOptions = this.collectVenueTypes(response.data || [])
}).catch(() => {
// 全量接口异常时保留已收集选项,避免影响列表的基本查询功能。
this.mergeVenueTypeOptions(this.tableData)
})
},
collectVenueTypes(classrooms) {
const venueTypes = new Set()
classrooms.forEach(classroom => {
const venueType = classroom.jxcdlx && String(classroom.jxcdlx).trim()
if (venueType) {
venueTypes.add(venueType)
}
})
return Array.from(venueTypes).sort((first, second) => first.localeCompare(second, 'zh-CN'))
},
mergeVenueTypeOptions(classrooms) {
const venueTypes = new Set(this.jxcdlxOptions)
this.collectVenueTypes(classrooms).forEach(venueType => venueTypes.add(venueType))
this.jxcdlxOptions = Array.from(venueTypes)
.sort((first, second) => first.localeCompare(second, 'zh-CN'))
},
/* ---------- 列表加载 ---------- */
fetchList() {
this.loading = true
const params = {
pageNum: this.pageNum,
pageSize: this.pageSize
}
Object.keys(this.searchForm).forEach(key => {
const value = this.searchForm[key]
if (value !== '' && value !== null && value !== undefined) {
params[key] = value
}
})
listClassroom(params).then(response => {
const data = response.data || {}
this.tableData = data.records || []
this.mergeVenueTypeOptions(this.tableData)
this.total = data.total || 0
}).catch(() => {
this.tableData = []
this.total = 0
}).finally(() => {
this.loading = false
})
},
/* ---------- 查询 / 重置 ---------- */
handleQuery() {
this.pageNum = 1
this.fetchList()
},
handleReset() {
this.searchForm = {
jsbh: '',
jsmc: '',
jxldh: '',
jxcdlx: '',
ty: false
}
this.pageNum = 1
this.fetchList()
},
/* ---------- 分页 ---------- */
handleSizeChange(size) {
this.pageSize = size
this.pageNum = 1
this.fetchList()
},
handlePageChange(page) {
this.pageNum = page
this.fetchList()
},
/* ---------- 新增 / 编辑 ---------- */
handleAdd() {
this.dialog.title = '新增教学场地'
this.dialog.form = this.createEmptyForm()
this.dialog.visible = true
this.$nextTick(() => {
if (this.$refs.classroomForm) this.$refs.classroomForm.clearValidate()
})
},
handleEdit(row) {
this.dialog.title = '编辑教学场地'
this.dialog.form = Object.assign({}, this.createEmptyForm(), row)
this.dialog.visible = true
this.$nextTick(() => {
if (this.$refs.classroomForm) this.$refs.classroomForm.clearValidate()
})
},
submitDialog() {
this.$refs.classroomForm.validate(valid => {
if (!valid) return
this.dialog.submitting = true
const payload = this.cleanPayload(this.dialog.form)
const isEdit = !!payload.id
const req = isEdit ? updateClassroom(payload) : addClassroom(payload)
req.then(() => {
this.$message.success(isEdit ? '修改成功' : '新增成功')
this.dialog.visible = false
this.loadVenueTypeOptions()
this.fetchList()
}).catch(() => {}).finally(() => {
this.dialog.submitting = false
})
})
},
/* ---------- 删除(后端仅提供单条删除,参数为 id) ---------- */
handleDelete(row) {
this.$confirm('确定删除教室「' + (row.jsmc || row.jsbh) + '」吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
return deleteClassroom(row.id)
}).then(() => {
this.$message.success('删除成功')
this.loadVenueTypeOptions()
this.fetchList()
}).catch(() => {})
},
/* ---------- 模板下载 ---------- */
handleDownloadTemplate() {
downloadClassroomTemplate().then(blob => {
saveAs(blob, '教学场地管理模板.xls')
this.$message.success('模板下载成功')
}).catch(() => {})
},
/* ---------- 工具 ---------- */
createEmptyForm() {
return {
id: '',
jsbh: '',
jsmc: '',
jxldh: '',
jxcdlx: '',
jc: '',
rnrs: null,
mj: null,
ksrnrs: null,
ewyxkbs: null,
xh: '',
py: '',
xslx: false,
ty: false,
sssb: '',
bz: ''
}
},
/** 移除空值(''/null/undefined),保留 0/false 等有效值 */
cleanPayload(obj) {
const payload = {}
Object.keys(obj).forEach(key => {
const value = obj[key]
if (value !== '' && value !== null && value !== undefined) {
payload[key] = value
}
})
return payload
},
isTrue(val) {
return val === true || val === 1 || val === '1' || val === 'true'
},
fmtXslx(val) {
return this.isTrue(val) ? '实教' : '非实教'
}
}
}
</script>
<style scoped lang="scss">
.app-container {
padding: 20px;
}
.venue-page {
.search-card {
margin-bottom: 16px;
.search-form {
.w-full {
width: 100%;
}
.search-actions {
padding-top: 4px;
}
}
}
.table-card {
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.list-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.pagination {
margin-top: 16px;
text-align: right;
}
}
}
.number-unit-field {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
.el-input-number {
flex: 1;
width: auto;
min-width: 0;
}
.number-unit {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
height: 36px;
color: #606266;
}
}
.danger-text-btn {
color: #f56c6c;
&:hover {
color: #f78989;
}
}
</style>