Files
education/frontend/src/views/teachBusiness/schedulingWindow/index.vue
T
liweijie 094245231c 1、新增教学大纲,报专业名称重复,其实是code重复,修复;
2、已经限制教员排课的,教员仍然可以排课的问题修复
2026-09-24 11:13:23 +08:00

2090 lines
72 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="app-container management-page scheduling-page">
<!-- ==================== 页头 ==================== -->
<div class="page-head">
<div class="page-title">排课窗口</div>
<div class="head-tools">
<el-select
v-model="xydxqbh"
filterable
clearable
placeholder="选择班次学期"
style="width: 340px"
@change="loadView"
>
<el-option
v-for="s in semesterOptions"
:key="s.bh"
:label="semesterLabel(s)"
:value="s.bh"
/>
</el-select>
<el-button icon="el-icon-refresh" :loading="loading" @click="loadView">刷新</el-button>
</div>
</div>
<el-card v-if="!xydxqbh" shadow="never">
<div class="empty-tip">请先选择班次学期再在左侧选择课程右侧格子中选择节次</div>
</el-card>
<template v-else>
<!-- ==================== 班次头 ==================== -->
<el-card shadow="never" class="semester-card">
<div class="semester-head">
<div class="semester-name">
{{ view.xydmc || '—' }}
<el-tag v-if="view.zyjsbh" size="mini" type="success" class="ml8">专用教室 {{ view.zyjsbh }}</el-tag>
</div>
<div class="semester-meta">
<span>学期代号{{ view.ndCode || '—' }}</span>
<span>起止{{ fmtDate(view.kxrq) }} ~ {{ fmtDate(view.jsrq) }}</span>
<span>总周数{{ view.totalWeeks || (weeks.length || '—') }}</span>
<span>课程{{ courses.length }} </span>
</div>
</div>
</el-card>
<el-row :gutter="16">
<!-- ==================== 课程列表 ==================== -->
<el-col :span="7">
<el-card shadow="never" class="panel-card">
<div slot="header" class="panel-head">
<span>课程列表</span>
<span class="panel-tip">点选课程 右侧选节次</span>
</div>
<div v-loading="loading" class="course-list">
<div v-if="!courses.length" class="empty-tip">该班次学期暂无可排课程</div>
<div
v-for="(c, i) in courses"
:key="c.sskcbh + '#' + i"
class="course-item"
:class="{ 'is-active': currentCourse && currentCourse.sskcbh === c.sskcbh, 'is-full': c.full, 'is-readonly': !c.editable }"
@click="selectCourse(c)"
>
<div class="ci-line1">
<span class="ci-name" :title="c.kcmc || c.sskcbh">{{ c.kcmc || '未命名课程' }}</span>
<el-tag v-if="c.jc" size="mini" type="info" class="ml8">{{ c.jc }}</el-tag>
<el-tag v-if="c.full" size="mini" type="success">已排满</el-tag>
<el-tag v-else-if="c.xs" size="mini" type="warning">待排 {{ remainHours(c) }}</el-tag>
<el-tag v-if="!c.editable" size="mini" type="info">只读</el-tag>
</div>
<div class="ci-line2">
<span>教员{{ c.jyxm || '—' }}</span>
<span>场地{{ c.jsmc || c.jsbh || '未设置' }}</span>
</div>
<div class="ci-line2">
<span v-if="c.teams && c.teams.length > 1" class="ci-merge">合班 {{ c.teams.length }} 个班次{{ c.teams.map(t => t.xydmc).join('、') }}</span>
<span v-else class="ci-merge">班次{{ (c.teams && c.teams[0] && c.teams[0].xydmc) || view.xydmc || '—' }}</span>
</div>
<div v-if="c.jysjhbz || c.jysjhjyxm" class="ci-line2 ci-advice">
<span v-if="c.jysjhjyxm">教研室计划教员{{ c.jysjhjyxm }}</span>
<span v-if="c.jysjhbz" :title="c.jysjhbz">排课建议{{ c.jysjhbz }}</span>
</div>
<div class="ci-line3">
<el-progress
:percentage="progressOf(c)"
:stroke-width="6"
:show-text="false"
:color="c.full ? '#0b6b55' : '#c69b3c'"
/>
<span class="ci-hours">{{ c.xs ? (c.scheduledHours || 0) + ' / ' + c.xs + ' 学时' : '学时未设置' }}</span>
</div>
<div class="ci-line4">
<span>课程编号 {{ shortId(c.sskcbh) }}</span>
<el-button v-if="c.editable" type="text" size="mini" class="ci-edit" @click.stop="openCourseEdit(c)">编辑课程信息</el-button>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- ==================== 周次网格 ==================== -->
<el-col :span="17">
<el-card shadow="never" class="panel-card">
<div slot="header" class="panel-head">
<div class="week-nav">
<el-radio-group v-model="viewMode" size="mini" style="margin-right: 12px">
<el-radio-button label="week">单周视图</el-radio-button>
<el-radio-button label="all">全学期视图</el-radio-button>
<el-radio-button label="h">横版视图</el-radio-button>
</el-radio-group>
<template v-if="viewMode !== 'all'">
<el-button size="mini" icon="el-icon-arrow-left" :disabled="weekIndex <= 0" @click="weekIndex--">上一周</el-button>
<span class="week-text">
<b>{{ currentWeek ? currentWeek.weekNo : '—' }}</b>
<span class="week-range" v-if="currentWeek">{{ fmtDate(currentWeek.startDate) }} ~ {{ fmtDate(currentWeek.endDate) }}</span>
</span>
<el-button size="mini" :disabled="weekIndex >= weeks.length - 1" @click="weekIndex++">下一周<i class="el-icon-arrow-right el-icon--right" /></el-button>
</template>
<span v-else class="week-text">全学期 <b>{{ weeks.length }}</b> 可直接跨周框选</span>
</div>
<div class="legend">
<span class="lg"><i class="dot dot-free" />可排</span>
<span class="lg"><i class="dot dot-picked" />已选</span>
<span class="lg"><i class="dot dot-lesson" />已排课次</span>
<span class="lg"><i class="dot dot-cur" />当前课程</span>
<span class="lg"><i class="dot dot-warn" />软提示</span>
<span class="lg"><i class="dot dot-conflict" />冲突</span>
<span class="lg"><i class="dot dot-block" />不可排</span>
</div>
<div class="grid-controls">
<span class="ctl-label">冲突显示</span>
<el-checkbox-group v-model="conflictDims" size="mini" class="ctl-group">
<el-checkbox-button v-for="d in conflictDimOptions" :key="d.key" :label="d.key">{{ d.label }}</el-checkbox-button>
</el-checkbox-group>
<span class="ctl-label" style="margin-left: 10px">显示节次</span>
<el-checkbox-group v-model="periodToggles" size="mini" class="ctl-group">
<el-checkbox-button label="p78">7-8 </el-checkbox-button>
<el-checkbox-button label="eve">晚上</el-checkbox-button>
<el-checkbox-button label="night">夜间</el-checkbox-button>
</el-checkbox-group>
<el-button size="mini" type="text" @click="conflictDims = []; periodToggles = ['p78','eve','night']">重置</el-button>
<el-checkbox v-model="moveMode" size="mini" style="margin-left: 10px">拖拽调课</el-checkbox>
<el-button size="mini" icon="el-icon-download" style="margin-left: 8px" @click="handleExport">导出</el-button>
<el-button size="mini" icon="el-icon-printer" @click="handlePrint">打印</el-button>
</div>
</div>
<div
ref="gridWrap"
v-loading="loading"
class="timetable-wrap"
tabindex="0"
@mouseup="finishDrag"
@mouseleave="finishDrag"
@keydown="onKeydown"
>
<div class="timetable" :class="{ 'is-overview': viewMode === 'all' }">
<template v-for="row in gridRows">
<div v-if="viewMode === 'all'" :key="'wk' + row.weekNo" class="tb-weekline"> {{ row.weekNo }} </div>
<div class="tb-row tb-head">
<div class="tb-cell tb-corner">节次</div>
<div
v-for="d in row.days"
:key="row.key + d.date"
class="tb-cell tb-day"
:class="{ 'day-off': !!d.unavailableReason }"
:title="d.unavailableReason || ''"
>
<div class="day-name">{{ weekdayName(d.weekday) }}</div>
<div class="day-date">{{ fmtDate(d.date) }}</div>
</div>
</div>
<div v-for="jc in jcList" :key="row.key + '#' + jc" class="tb-row">
<div class="tb-cell tb-jc" :title="periodLabel(jc)">{{ jc }}</div>
<div
v-for="d in row.days"
:key="d.date + '#' + jc"
class="tb-cell tb-slot"
:class="slotClass(d, jc)"
:title="slotTitle(d, jc)"
@mousedown.prevent="startDrag(d.fi, jc, d, $event)"
@mouseenter="hoverCell(d.fi, jc, $event)"
@dblclick="dblclickCell(d, jc)"
@dragover="onCellDragover(d, jc, $event)"
@dragleave="onCellDragleave(d, jc)"
@drop="onCellDrop(d, jc, $event)"
>
<template v-if="lessonsOf(d, jc).length">
<div
v-for="l in lessonsOf(d, jc)"
:key="l.bh"
class="lesson"
:class="{ 'is-current': currentCourse && l.sskcbh === currentCourse.sskcbh, 'is-draggable': moveMode && lessonEditable(l) }"
:draggable="moveMode && lessonEditable(l)"
@dragstart="onLessonDragstart(l, d, jc, $event)"
>
<div class="lesson-name">{{ shortName(l) }}</div>
<div class="lesson-meta">{{ [l.kcmc ? l.jxnr : '', l.jyxm, l.jsmc].filter(Boolean).join(' · ') || '—' }}</div>
</div>
</template>
<div v-else-if="isSelected(d.date, jc)" class="slot-picked">已选</div>
<div v-else-if="cellOf(d, jc) && cellOf(d, jc).unavailable" class="slot-block">{{ cellOf(d, jc).reason || '不可排' }}</div>
</div>
</div>
</template>
<!-- ==================== 横版=日期 =节次E4 ==================== -->
<template v-if="viewMode === 'h' && currentWeek">
<div class="tb-row tb-head">
<div class="tb-cell tb-corner">日期</div>
<div
v-for="jc in jcList"
:key="'hjc' + jc"
class="tb-cell tb-day"
:title="periodLabel(jc)"
>{{ jc }}</div>
</div>
<div v-for="d in hDays" :key="'hd' + d.date" class="tb-row">
<div class="tb-cell tb-jc" :class="{ 'day-off': !!d.unavailableReason }" :title="d.unavailableReason || ''">
{{ weekdayName(d.weekday) }} {{ fmtDate(d.date) }}
</div>
<div
v-for="jc in jcList"
:key="d.date + '#h#' + jc"
class="tb-cell tb-slot"
:class="slotClass(d, jc)"
:title="slotTitle(d, jc)"
@mousedown.prevent="startDrag(d.fi, jc, d, $event)"
@mouseenter="hoverCell(d.fi, jc, $event)"
@dblclick="dblclickCell(d, jc)"
@dragover="onCellDragover(d, jc, $event)"
@dragleave="onCellDragleave(d, jc)"
@drop="onCellDrop(d, jc, $event)"
>
<template v-if="lessonsOf(d, jc).length">
<div
v-for="l in lessonsOf(d, jc)"
:key="l.bh"
class="lesson"
:class="{ 'is-current': currentCourse && l.sskcbh === currentCourse.sskcbh, 'is-draggable': moveMode && lessonEditable(l) }"
:draggable="moveMode && lessonEditable(l)"
@dragstart="onLessonDragstart(l, d, jc, $event)"
>
<div class="lesson-name">{{ shortName(l) }}</div>
<div class="lesson-meta">{{ [l.kcmc ? l.jxnr : '', l.jyxm, l.jsmc].filter(Boolean).join(' · ') || '—' }}</div>
</div>
</template>
<div v-else-if="isSelected(d.date, jc)" class="slot-picked">已选</div>
<div v-else-if="cellOf(d, jc) && cellOf(d, jc).unavailable" class="slot-block">{{ cellOf(d, jc).reason || '不可排' }}</div>
</div>
</div>
</template>
<div v-if="!jcList.length" class="empty-tip">该班次学期暂无可用节次</div>
</div>
</div>
<!-- ==================== 当前格子信息 + 选择区域统计 ==================== -->
<div class="cell-info-bar">
<div class="ci-block">
<div class="ci-title">当前格子编排信息</div>
<div v-if="!focusedCell" class="ci-empty">点选格子查看详情双击已排格子可回填预排信息</div>
<div v-else class="ci-body">
<div class="ci-row">
<span class="ci-k">日期节次</span>
<span>{{ focusedCell.rq }} {{ focusedCell.jc }} </span>
</div>
<div v-if="focusedCellCell && focusedCellCell.reason" class="ci-row">
<span class="ci-k">冲突</span>
<span class="ci-danger">{{ focusedCellCell.reason }}</span>
</div>
<div v-if="focusedCellCell && focusedCellCell.warning" class="ci-row">
<span class="ci-k">提示</span>
<span class="ci-warn">{{ focusedCellCell.warning }}</span>
</div>
<div v-if="focusedLessons.length" class="ci-row">
<span class="ci-k">已排课次</span>
<span>
<div v-for="l in focusedLessons" :key="l.bh" class="ci-lesson">
<b>{{ l.kcmc }}</b>
<span v-if="l.jxnr">{{ l.jxnr }}</span>
<span v-if="l.jxff">{{ l.jxff }}</span>
<span v-if="l.jyxm">{{ l.jyxm }}</span>
<span v-if="l.jsmc">{{ l.jsmc }}</span>
<span v-if="l.jcdj">节次调节 {{ l.jcdj > 0 ? '+' : '' }}{{ l.jcdj }}</span>
<span v-if="l.xydbhs && l.xydbhs.length">班次 {{ l.xydbhs.length }} </span>
<span v-if="l.jxbzbz">保障 {{ l.jxbzbz }}</span>
<span v-if="l.ycxx">用车 {{ l.ycxx }}</span>
</div>
</span>
</div>
<div v-else class="ci-empty">该格暂未排课</div>
</div>
</div>
<div class="ci-block">
<div class="ci-title">选择区域统计</div>
<div v-if="!selectedCells.length" class="ci-empty">框选节次后显示统计</div>
<div v-else class="ci-body">
<div class="ci-row"><span class="ci-k">格数</span><span>{{ selectedCells.length }} 合计 {{ selectedHours }} 学时</span></div>
<div class="ci-row"><span class="ci-k">日期范围</span><span>{{ statsRangeText }}</span></div>
<div class="ci-row">
<span class="ci-k">按周分布</span>
<span>{{ statsWeekText }}</span>
</div>
<div class="ci-row">
<span class="ci-k">当前课程占</span>
<span>{{ pickedLessonCount }} 个课次{{ currentCourse ? (currentCourse.kcmc || '') : '未选课程' }}</span>
</div>
<div v-if="statsOthers.length" class="ci-row">
<span class="ci-k">其它课程</span>
<span>{{ statsOthers.join('') }}</span>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- ==================== 操作条 ==================== -->
<div class="op-bar">
<div class="op-left">
<span class="op-course">
当前课程<b>{{ currentCourse ? currentCourse.kcmc : '未选择' }}</b>
</span>
<span class="op-picked">
已选 <b>{{ selectedCells.length }}</b>
<el-button v-if="selectedCells.length" type="text" size="mini" @click="clearSelection">清除选择</el-button>
</span>
<span class="op-hint">快捷键S 安排 · R 删除不在输入框时可加 Alt· Ctrl+框选多区域 · 双击回填</span>
</div>
<div class="op-right">
<el-button
type="primary"
icon="el-icon-plus"
:disabled="!canArrange"
:loading="submitting"
@click="openArrangeForm"
>排入所选节次</el-button>
<el-button
icon="el-icon-delete"
:disabled="!canOperate || !pickedLessonCount"
:loading="submitting"
@click="handleDeleteCells"
>删除所选节次</el-button>
<el-button
v-if="currentCourse && currentCourse.editable"
icon="el-icon-setting"
@click="openCourseEdit(currentCourse)"
>编辑课程信息</el-button>
<el-button
icon="el-icon-circle-close"
:disabled="!currentCourse || !currentCourse.editable"
@click="handleClearCourse"
>清空该课程节次</el-button>
<el-button
type="danger"
plain
icon="el-icon-close"
:disabled="!currentCourse || !currentCourse.editable"
@click="handleDeleteCourse"
>删除运行课程</el-button>
<el-button
icon="el-icon-time"
:disabled="!currentCourse"
@click="handleOpenLogs"
>排课日志</el-button>
</div>
</div>
</template>
<!-- ==================== 排课信息弹窗 ==================== -->
<el-dialog title="排入所选节次" :visible.sync="formVisible" width="760px" append-to-body>
<div class="form-summary">
<span>课程<b>{{ currentCourse ? currentCourse.kcmc : '—' }}</b></span>
<span>节次<b>{{ selectedCells.length }}</b> </span>
<span v-if="selectedRangeText">范围{{ selectedRangeText }}</span>
</div>
<div v-if="currentCourse && currentCourse.jysjhbz" class="advice-bar">
排课建议{{ currentCourse.jysjhbz }}
</div>
<el-tabs v-model="formTab" class="form-tabs">
<!-- 课程科目信息 -->
<el-tab-pane label="课程科目信息" name="subject">
<el-form :model="arrangeForm" label-width="110px" size="small">
<el-form-item label="科目名称">
<el-input :value="currentCourse ? currentCourse.kcmc : ''" disabled />
</el-form-item>
<el-form-item label="教学方法">
<el-select v-model="arrangeForm.jxff" style="width: 100%">
<el-option v-for="m in jxffOptions" :key="m" :label="m" :value="m" />
</el-select>
</el-form-item>
<el-form-item label="教学内容">
<el-input v-model="arrangeForm.jxnr" type="textarea" :rows="2" maxlength="200" show-word-limit placeholder="本次课课题 / 教学内容" />
</el-form-item>
<el-form-item label="教学要点">
<el-input v-model="arrangeForm.jxyd" type="textarea" :rows="2" maxlength="200" show-word-limit placeholder="选填" />
</el-form-item>
<el-form-item label="节次调节">
<el-select v-model="arrangeForm.jcdj" style="width: 100%">
<el-option label="默认(每节 2 学时)" :value="0" />
<el-option label="减 1 学时" :value="-1" />
<el-option label="加 1 学时" :value="1" />
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 教学班次信息 -->
<el-tab-pane :label="'教学班次(' + arrangeTeams.length + ''" name="teams">
<div class="pane-tip">默认使用该课程的全部合班班次实践教学拆分时移除其它班次需要集中上课时可加入其它班次</div>
<div class="pane-list">
<div v-for="t in arrangeTeams" :key="t.xydbh" class="pane-item">
<span class="pi-main">{{ t.xydmc || t.xydbh }}</span>
<span class="pi-sub">{{ t.xydbh }}<template v-if="t.rs">{{ t.rs }} </template></span>
<el-button type="text" size="mini" class="pi-del" @click="removeTeam(t)">移除</el-button>
</div>
<div v-if="!arrangeTeams.length" class="empty-tip">至少保留一个教学班次</div>
</div>
<div class="pane-add">
<el-select v-model="teamToAdd" filterable clearable placeholder="选择要加入的班次(本学期同课程其它班次)" style="width: 380px">
<el-option
v-for="t in addableTeams"
:key="t.xydbh + '#' + (t.xydxqbh || '')"
:label="(t.xydmc || t.xydbh) + (t.xydxqbh ? '' + t.xydxqbh : '')"
:value="t.xydbh"
/>
</el-select>
<el-button icon="el-icon-plus" @click="addTeam">增加教学班次</el-button>
</div>
</el-tab-pane>
<!-- 授课教员信息 -->
<el-tab-pane :label="'授课教员(' + (1 + arrangeForm.fzjybhs.length) + ''" name="teachers">
<el-form label-width="110px" size="small">
<el-form-item label="主讲教员(+)">
<el-input
v-model="arrangeForm.jybh"
clearable
:placeholder="currentCourse && currentCourse.jybh ? '缺省用课程责任教员 ' + (currentCourse.jyxm || currentCourse.jybh) : '缺省用课程责任教员'"
/>
</el-form-item>
<el-form-item label="辅讲教员(-)">
<div class="pane-add">
<el-input v-model="fzjyToAdd" clearable placeholder="输入教员工号后点增加" style="width: 260px" />
<el-button icon="el-icon-plus" @click="addFzjy">增加辅讲教员</el-button>
</div>
<div v-if="arrangeForm.fzjybhs.length" class="pane-list" style="margin-top: 8px">
<div v-for="(f, i) in arrangeForm.fzjybhs" :key="f" class="pane-item">
<span class="pi-main">- {{ f }}</span>
<el-button type="text" size="mini" class="pi-del" @click="arrangeForm.fzjybhs.splice(i, 1)">移除</el-button>
</div>
</div>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 教学场地信息 -->
<el-tab-pane :label="'教学场地(' + arrangeForm.jsbhs.length + ''" name="rooms">
<div class="pane-tip">默认使用课程默认场地可加入多个场地如理论教室 + 实践场地</div>
<div class="pane-list">
<div v-for="(r, i) in arrangeForm.jsbhs" :key="r" class="pane-item">
<span class="pi-main">{{ roomLabel(r) }}</span>
<span class="pi-sub">{{ r }}</span>
<el-button type="text" size="mini" class="pi-del" @click="arrangeForm.jsbhs.splice(i, 1)">移除</el-button>
</div>
<div v-if="!arrangeForm.jsbhs.length" class="empty-tip">未指定时使用课程默认场地未设置则会排课失败</div>
</div>
<div class="pane-add">
<el-select
v-model="roomToAdd"
filterable
allow-create
default-first-option
clearable
placeholder="候选教学场地(可输入新编号)"
style="width: 380px"
>
<el-option
v-for="r in classroomOptions"
:key="r.jsbh"
:label="r.jsmc ? r.jsbh + '' + r.jsmc + '' : r.jsbh"
:value="r.jsbh"
/>
</el-select>
<el-button icon="el-icon-plus" @click="addRoom">增加教学场地</el-button>
</div>
</el-tab-pane>
<!-- 教学保障信息保障资源暂无主数据改为手动录入 -->
<el-tab-pane label="教学保障" name="support">
<div class="pane-tip">
保障资源尚未登记主数据直接手动录入保障需求文字保存到教学保障备注
</div>
<el-form :model="arrangeForm" label-width="110px" size="small" style="margin-top: 10px">
<el-form-item label="教学保障需求">
<el-input
v-model="arrangeForm.jxbzbz"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="如:运输车 2 台(含司机)、靶具 10 套;需提前 30 分钟到场布置…"
/>
</el-form-item>
<el-form-item label="用车信息">
<div class="car-row">
<el-input v-model="carDepartTime" placeholder="出发时间" style="width: 130px" />
<el-input v-model="carDepartPlace" placeholder="出发地点" style="width: 160px" />
<el-input v-model="carReturnTime" placeholder="返回时间" style="width: 130px" />
<el-input v-model="carDest" placeholder="目的地点" style="width: 160px" />
</div>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<div slot="footer">
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitArrange">确认排课</el-button>
</div>
</el-dialog>
<!-- ==================== 编辑课程教学任务信息 ==================== -->
<el-dialog title="编辑课程教学任务信息" :visible.sync="courseFormVisible" width="520px" append-to-body>
<el-form :model="courseForm" label-width="120px" size="small">
<el-form-item label="课程">
<el-input :value="courseForm.kcmc" disabled />
</el-form-item>
<el-form-item label="课程简称">
<el-input v-model="courseForm.jc" clearable maxlength="20" placeholder="格内显示用的简称" />
</el-form-item>
<el-form-item label="学时">
<el-input-number v-model="courseForm.xs" :min="0" :max="1000" />
</el-form-item>
<el-form-item label="周课时">
<el-input-number v-model="courseForm.zks" :min="0" :max="60" />
</el-form-item>
<el-form-item label="责任教员编号">
<el-input v-model="courseForm.jybh" clearable placeholder="教员工号" />
</el-form-item>
<el-form-item label="默认教学场地">
<el-select v-model="courseForm.jsbh" filterable clearable allow-create default-first-option style="width: 100%">
<el-option
v-for="r in classroomOptions"
:key="r.jsbh"
:label="r.jsmc ? r.jsbh + '' + r.jsmc + '' : r.jsbh"
:value="r.jsbh"
/>
</el-select>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="courseFormVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitCourseEdit">保存</el-button>
</div>
</el-dialog>
<!-- ==================== 排课日志弹窗 ==================== -->
<el-dialog title="排课日志" :visible.sync="logsVisible" width="760px" append-to-body>
<el-table :data="logs" size="small" border stripe max-height="420">
<el-table-column prop="czlx" label="操作类型" width="110" align="center" />
<el-table-column prop="cznr" label="操作内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="czrmc" label="操作人" width="110" align="center" />
<el-table-column prop="czsj" label="时间" width="160" align="center" />
</el-table>
<div v-if="!logs.length" class="empty-tip">暂无排课日志</div>
</el-dialog>
</div>
</template>
<script>
import {
getSchedulingView,
arrangeLessons,
moveLesson,
exportSchedulingView,
deleteLessonCells,
clearCourseLessons,
deleteRunningCourse,
listSchedulingLogs,
updateSchedulingCourse,
getCourseTeams
} from '@/api/teachBusiness/schedulingWindow'
import { listSemester } from '@/api/studentRecords/semester'
import { listAllClassroom } from '@/api/teachBusiness/classroom'
import { saveAs } from 'file-saver'
export default {
name: 'SchedulingWindow',
data() {
return {
semesterOptions: [],
classroomOptions: [],
xydxqbh: '',
view: {},
weekIndex: 0,
loading: false,
submitting: false,
currentCourse: null,
/** 已选格子 [{rq, jc}] */
selectedCells: [],
dragStart: null,
dragEnd: null,
dragging: false,
/** 本次拖拽是否为追加(Ctrl / Meta */
additiveDrag: false,
/** 按下时的指针位置,用于忽略首次聚焦滚动触发的误 hover */
dragOrigin: null,
/** 当前聚焦格(⑨ 当前格子编排信息) */
focusedCell: null,
/** 视图模式:week 单周 / all 全学期 / h 横版 */
viewMode: 'week',
/** 拖拽调课模式(E4):开启后已排课次可拖到其它格 */
moveMode: false,
/** 当前拖放悬停目标格 key */
dropTarget: null,
/** 冲突显示选项(按维度着色) */
conflictDims: [],
conflictDimOptions: [
{ key: 'ROOM', label: '教室' },
{ key: 'TEACHER', label: '教员' },
{ key: 'TEAM', label: '班次' },
{ key: 'ROOM_CAL', label: '场地历' },
{ key: 'TEACHER_CAL', label: '教员历' },
{ key: 'CALENDAR', label: '校历/班历' }
],
/** 节次显示控制(④ 显示控制选项) */
periodToggles: ['p78', 'eve', 'night'],
formVisible: false,
formTab: 'subject',
logsVisible: false,
logs: [],
jxffOptions: ['理论讲授', '实践操作', '案例研讨', '综合演练', '现地教学', '自学'],
arrangeForm: {
jxnr: '',
jxyd: '',
jxff: '理论讲授',
jcdj: 0,
jxbzbz: '',
ycxx: '',
jsbh: '',
jybh: '',
jsbhs: [],
fzjybhs: [],
xydbhs: []
},
/** 教学班次面板 */
arrangeTeams: [],
candidateTeams: [],
teamToAdd: '',
fzjyToAdd: '',
roomToAdd: '',
/** 用车信息四要素 */
carDepartTime: '',
carDepartPlace: '',
carReturnTime: '',
carDest: '',
/** 编辑课程教学任务信息 */
courseFormVisible: false,
courseForm: { sskcbh: '', kcmc: '', jc: '', xs: 0, zks: 0, jybh: '', jsbh: '' }
}
},
computed: {
courses() {
return this.view && this.view.courses ? this.view.courses : []
},
weeks() {
return this.view && this.view.weeks ? this.view.weeks : []
},
currentWeek() {
return this.weeks[this.weekIndex] || null
},
days() {
return this.currentWeek && this.currentWeek.days ? this.currentWeek.days : []
},
/** 全学期扁平日列表(跨周框选用) */
flatDays() {
const list = []
const index = {}
this.weeks.forEach(w => {
(w.days || []).forEach(d => {
index[this.fmtDate(d.date)] = list.length
list.push(d)
})
})
return { list, index }
},
/** 网格行:单周一行 / 全学期每周一组;横版走 hDays */
gridRows() {
if (this.viewMode === 'h') return []
if (this.viewMode === 'all') {
return this.weeks.map(w => ({
key: 'w' + w.weekNo,
weekNo: w.weekNo,
days: (w.days || []).map(d => Object.assign({}, d, { fi: this.fiOf(d.date) }))
}))
}
const w = this.currentWeek
if (!w) return []
return [{
key: 'w' + w.weekNo,
weekNo: w.weekNo,
days: (w.days || []).map(d => Object.assign({}, d, { fi: this.fiOf(d.date) }))
}]
},
/** 横版视图的当前周日列表(行=日期) */
hDays() {
const w = this.currentWeek
if (!w) return []
return (w.days || []).map(d => Object.assign({}, d, { fi: this.fiOf(d.date) }))
},
/** 节次字典(来自节次时间表),缺失时按数据推导 */
periodDict() {
const list = (this.view && this.view.periods) || []
if (list.length) return list
const set = new Set()
this.weeks.forEach(w => (w.days || []).forEach(d => (d.cells || []).forEach(c => set.add(c.jc))))
return Array.from(set).sort((a, b) => a - b).map(jc => ({ jc, label: String(jc), sd: jc <= 4 ? '上午' : '下午' }))
},
/** 应用显示控制后的节次行 */
jcList() {
return this.periodDict
.filter(p => this.periodVisible(p))
.map(p => p.jc)
},
canArrange() {
return !!(this.currentCourse && this.currentCourse.editable && this.selectedCells.length)
},
canOperate() {
return !!(this.currentCourse && this.currentCourse.editable)
},
/** 所选格中属于当前课程的已排课次数 */
pickedLessonCount() {
let n = 0
this.eachCell((day, cell) => {
if (this.isSelected(day.date, cell.jc) && this.currentCourse) {
n += (cell.lessons || []).filter(l => l.sskcbh === this.currentCourse.sskcbh).length
}
})
return n
},
/** 选区统计:合计学时(每节 2 学时) */
selectedHours() {
return this.selectedCells.length * 2
},
selectedRangeText() {
if (!this.selectedCells.length) return ''
const dates = Array.from(new Set(this.selectedCells.map(c => c.rq))).sort()
const jcs = Array.from(new Set(this.selectedCells.map(c => c.jc))).sort((a, b) => a - b)
const dateText = dates.length > 2 ? `${dates[0]} ~ ${dates[dates.length - 1]}${dates.length} 天)` : dates.join('、')
return `${dateText}${jcs.join('、')}`
},
statsRangeText() {
return this.selectedRangeText || '—'
},
/** 选区按周分布 */
statsWeekText() {
const counter = {}
this.selectedCells.forEach(c => {
const w = this.weekNoOfDate(c.rq)
counter[w] = (counter[w] || 0) + 1
})
const keys = Object.keys(counter).map(Number).sort((a, b) => a - b)
if (!keys.length) return '—'
return keys.map(w => `${w}${counter[w]}`).join('、')
},
/** 选区内其它课程的课次 */
statsOthers() {
const map = {}
this.eachCell((day, cell) => {
if (!this.isSelected(day.date, cell.jc)) return
;(cell.lessons || []).forEach(l => {
if (this.currentCourse && l.sskcbh === this.currentCourse.sskcbh) return
map[l.kcmc || '未知课程'] = (map[l.kcmc || '未知课程'] || 0) + 1
})
})
return Object.keys(map).map(k => `${k} ${map[k]}`)
},
focusedCellCell() {
if (!this.focusedCell) return null
return this.cellByKey(this.focusedCell.rq, this.focusedCell.jc)
},
focusedLessons() {
const cell = this.focusedCellCell
return cell && cell.lessons ? cell.lessons : []
},
/** 可加入的教学班次 */
addableTeams() {
const used = this.arrangeTeams.map(t => t.xydbh)
return this.candidateTeams.filter(t => !used.includes(t.xydbh))
}
},
created() {
if (this.$route.query && this.$route.query.xydxqbh) {
this.xydxqbh = this.$route.query.xydxqbh
}
this.loadSemesters()
this.loadClassrooms()
if (this.xydxqbh) this.loadView()
},
mounted() {
window.addEventListener('keydown', this.onGlobalKeydown)
},
beforeDestroy() {
window.removeEventListener('keydown', this.onGlobalKeydown)
},
methods: {
/* ==================== 数据加载 ==================== */
loadSemesters() {
listSemester({ pageNum: 1, pageSize: 200 }).then(res => {
const data = (res && res.data) || {}
this.semesterOptions = data.records || data.rows || []
}).catch(() => {
this.semesterOptions = []
})
},
loadClassrooms() {
listAllClassroom().then(res => {
this.classroomOptions = (res && res.data) || []
}).catch(() => {
this.classroomOptions = []
})
},
loadView() {
if (!this.xydxqbh) {
this.view = {}
this.currentCourse = null
this.selectedCells = []
this.focusedCell = null
return
}
this.loading = true
const keepBh = this.currentCourse ? this.currentCourse.sskcbh : null
getSchedulingView(this.xydxqbh).then(res => {
this.view = (res && res.data) || {}
this.selectedCells = []
this.focusedCell = null
this.dragging = false
this.dragStart = null
this.dragEnd = null
this.dragOrigin = null
// 尽量保留原来选中的课程
const list = this.courses
this.currentCourse = keepBh ? (list.find(c => c.sskcbh === keepBh) || list[0] || null) : (list[0] || null)
if (this.weekIndex >= this.weeks.length) this.weekIndex = 0
this.loading = false
}).catch(err => {
this.loading = false
this.view = {}
this.currentCourse = null
this.selectedCells = []
this.focusedCell = null
this.$message.warning('排课视图加载失败:该班次学期可能暂无课程数据,或课程/场地数据不完整')
if (window.console && console.error) console.error(err)
})
},
/* ==================== 工具 ==================== */
fmtDate(v) {
if (!v) return ''
if (Array.isArray(v)) {
const p = n => (n < 10 ? '0' + n : '' + n)
return `${v[0]}-${p(v[1])}-${p(v[2])}`
}
return String(v).slice(0, 10)
},
weekdayName(w) {
const names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
return names[w] || '—'
},
semesterLabel(s) {
const name = s.xydmc || s.xydbh || s.bh
const ext = []
if (s.nd) ext.push(s.nd + '年')
if (s.xqdc) ext.push('第' + s.xqdc + '学期')
if (Number(s.kfjypk) !== 1) ext.push('未开放教员排课')
return ext.length ? `${name}${ext.join(' ')}` : name
},
remainHours(c) {
const total = c.xs || 0
const done = c.scheduledHours || 0
return Math.max(total - done, 0)
},
shortId(v) {
if (!v) return '—'
const s = String(v)
return s.length > 8 ? s.slice(-8) : s
},
progressOf(c) {
const total = c.xs || 0
if (!total) return 0
const pct = Math.round(((c.scheduledHours || 0) / total) * 100)
return pct > 100 ? 100 : pct
},
cellOf(day, jc) {
if (!day || !day.cells) return null
return day.cells.find(c => c.jc === jc) || null
},
lessonsOf(day, jc) {
const cell = this.cellOf(day, jc)
return cell && cell.lessons ? cell.lessons : []
},
cellKey(rq, jc) {
return `${this.fmtDate(rq)}#${jc}`
},
/** 全学期扁平序号(跨周框选统一寻址) */
fiOf(date) {
const key = this.fmtDate(date)
const idx = this.flatDays.index[key]
return idx === undefined ? -1 : idx
},
eachCell(fn) {
this.weeks.forEach(w => (w.days || []).forEach(d => (d.cells || []).forEach(c => fn(d, c))))
},
cellByKey(rq, jc) {
const key = this.fmtDate(rq)
const idx = this.flatDays.index[key]
const day = idx === undefined ? null : this.flatDays.list[idx]
return day ? this.cellOf(day, jc) : null
},
weekNoOfDate(rq) {
const key = this.fmtDate(rq)
const hit = this.weeks.find(w => (w.days || []).some(d => this.fmtDate(d.date) === key))
return hit ? hit.weekNo : 0
},
/** ④ 显示控制选项:7-8 节 / 晚上 / 夜间 */
periodVisible(p) {
const jc = p.jc
if (jc <= 6) return true
if (jc === 7 || jc === 8) return this.periodToggles.includes('p78')
if (jc <= 10) return this.periodToggles.includes((p.sd || '') === '晚上' ? 'eve' : 'night')
return this.periodToggles.includes('night')
},
periodLabel(jc) {
const p = this.periodDict.find(x => x.jc === jc)
return p ? `${p.label || jc}${p.sd || ''}` : String(jc)
},
/** 格内显示课程简称(无简称退化为课程名) */
shortName(l) {
if (!l) return '课程'
const c = this.courses.find(x => x.sskcbh === l.sskcbh)
return (c && c.jc) || l.kcmc || l.jxnr || '课程'
},
roomLabel(jsbh) {
if (!jsbh) return ''
const r = this.classroomOptions.find(x => x.jsbh === jsbh)
return r ? (r.jsmc ? r.jsbh + '' + r.jsmc + '' : r.jsbh) : jsbh
},
/** ② 冲突显示选项:命中当前课程预排条件的冲突维度 */
conflictKinds(day, jc) {
const kinds = []
const cell = this.cellOf(day, jc)
if (!cell) return kinds
if (cell.calendarKind || cell.reason) kinds.push('CALENDAR')
const room = this.arrangeForm.jsbhs[0] || (this.currentCourse && this.currentCourse.jsbh)
const teacher = this.arrangeForm.jybh || (this.currentCourse && this.currentCourse.jybh)
const myCourse = this.currentCourse ? this.currentCourse.sskcbh : null
const mineTeams = this.currentCourse && this.currentCourse.teams
? this.currentCourse.teams.map(t => t.xydbh) : []
if (room && (cell.blockedRooms || []).includes(room)) kinds.push('ROOM_CAL')
if (teacher && (cell.blockedTeachers || []).includes(teacher)) kinds.push('TEACHER_CAL')
;(cell.lessons || []).forEach(l => {
if (l.sskcbh === myCourse) return
if (room && (l.jsbhs || []).includes(room)) kinds.push('ROOM')
if (teacher && (l.jybhs || []).includes(teacher)) kinds.push('TEACHER')
if ((l.xydbhs || []).some(t => mineTeams.includes(t))) kinds.push('TEAM')
})
return Array.from(new Set(kinds))
},
/** 按所选维度命中的冲突 */
conflictHit(day, jc) {
if (!this.conflictDims.length) return false
return this.conflictKinds(day, jc).some(k => this.conflictDims.includes(k))
},
isSelected(rq, jc) {
return this.selectedCells.some(c => c.rq === this.fmtDate(rq) && c.jc === jc)
},
slotClass(day, jc) {
const cell = this.cellOf(day, jc)
const hasLesson = !!(cell && cell.lessons && cell.lessons.length)
return {
'is-block': !!(cell && cell.unavailable),
'is-warn': !!(cell && !cell.unavailable && cell.warning),
'is-picked': !hasLesson && this.isSelected(day.date, jc),
'has-lesson': hasLesson,
'is-conflict': !!(cell && !cell.unavailable && this.conflictHit(day, jc)),
'drop-hover': this.moveMode && this.dropTarget === this.cellKey(day.date, jc),
'day-off': !!day.unavailableReason
}
},
slotTitle(day, jc) {
const cell = this.cellOf(day, jc)
const parts = []
if (day.unavailableReason) parts.push(day.unavailableReason)
if (cell) {
if (cell.reason) parts.push(cell.reason)
if (cell.warning) parts.push('提示:' + cell.warning)
if (cell.lessons && cell.lessons.length) {
cell.lessons.forEach(l => {
parts.push(`${l.kcmc || ''} ${l.jxnr || ''} ${l.jyxm || ''} ${l.jsmc || ''}`.trim())
})
}
}
return parts.join('')
},
/* ==================== 选择 ==================== */
selectCourse(c) {
if (this.currentCourse && this.currentCourse.sskcbh === c.sskcbh) {
this.currentCourse = null
return
}
this.currentCourse = c
if (!c.editable) {
this.$message.info(`«${c.kcmc || ''}» 当前账号无排课权限,仅可查看`)
}
},
startDrag(fi, jc, day, ev) {
const cell = this.cellOf(day, jc)
if (cell && cell.unavailable) {
this.$message.warning(cell.reason || '该节次不可排课')
return
}
// 拖拽调课模式下按住格子只做聚焦,课次块用 HTML5 拖拽移动
if (this.moveMode) {
this.focusCell(day.date, jc)
const wrap = this.$refs.gridWrap
if (wrap && wrap.focus) wrap.focus({ preventScroll: true })
return
}
this.dragging = true
this.additiveDrag = !!(ev && (ev.ctrlKey || ev.metaKey))
this.dragStart = { fi, jc }
this.dragEnd = { fi, jc }
this.dragOrigin = ev ? { x: ev.clientX, y: ev.clientY } : null
this.focusCell(day.date, jc)
const wrap = this.$refs.gridWrap
if (wrap && wrap.focus) wrap.focus({ preventScroll: true })
},
hoverCell(fi, jc, ev) {
if (!this.dragging) return
// 刚进入页面首次单击会 focus 课表导致滚动,DOM 从指针下经过时误发 mouseenter。
// 指针未真正移动时不把结束格改成别的格子。
const origin = this.dragOrigin
if (origin && ev) {
const dx = Math.abs(ev.clientX - origin.x)
const dy = Math.abs(ev.clientY - origin.y)
if (dx < 6 && dy < 6) return
}
this.dragEnd = { fi, jc }
},
finishDrag() {
if (!this.dragging) return
this.dragging = false
const s = this.dragStart
const e = this.dragEnd
const additive = this.additiveDrag
this.dragStart = null
this.dragEnd = null
this.additiveDrag = false
this.dragOrigin = null
if (!s || !e) return
const fiFrom = Math.min(s.fi, e.fi)
const fiTo = Math.max(s.fi, e.fi)
const jcFrom = Math.min(s.jc, e.jc)
const jcTo = Math.max(s.jc, e.jc)
const picked = []
for (let fi = fiFrom; fi <= fiTo; fi++) {
const day = this.flatDays.list[fi]
if (!day || day.unavailableReason) continue
for (let jc = jcFrom; jc <= jcTo; jc++) {
const cell = this.cellOf(day, jc)
if (cell && cell.unavailable) continue
picked.push({ rq: this.fmtDate(day.date), jc })
}
}
const map = new Map()
if (additive) {
this.selectedCells.forEach(c => map.set(this.cellKey(c.rq, c.jc), c))
}
// 单格且非追加:点一下选中/取消
if (picked.length === 1 && !additive) {
const one = picked[0]
if (this.isSelected(one.rq, one.jc)) {
this.selectedCells = []
return
}
this.selectedCells = [one]
return
}
picked.forEach(c => map.set(this.cellKey(c.rq, c.jc), c))
this.selectedCells = Array.from(map.values())
.sort((a, b) => (a.rq === b.rq ? a.jc - b.jc : a.rq < b.rq ? -1 : 1))
},
clearSelection() {
this.selectedCells = []
},
/* ==================== 拖拽调课(E4 ==================== */
lessonEditable(l) {
const c = this.courses.find(x => x.sskcbh === l.sskcbh)
return !!(c && c.editable)
},
onLessonDragstart(l, day, jc, ev) {
if (!this.moveMode || !this.lessonEditable(l)) {
ev.preventDefault()
return
}
ev.dataTransfer.setData('text/plain', JSON.stringify({
sskcbh: l.sskcbh,
rq: this.fmtDate(day.date),
jc
}))
ev.dataTransfer.effectAllowed = 'move'
ev.stopPropagation()
},
onCellDragover(day, jc, ev) {
if (!this.moveMode) return
const cell = this.cellOf(day, jc)
if (cell && cell.unavailable) return
ev.preventDefault()
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move'
this.dropTarget = this.cellKey(day.date, jc)
},
onCellDragleave(day, jc) {
if (this.dropTarget === this.cellKey(day.date, jc)) this.dropTarget = null
},
onCellDrop(day, jc, ev) {
if (!this.moveMode) return
ev.preventDefault()
this.dropTarget = null
let payload = null
try {
payload = JSON.parse(ev.dataTransfer.getData('text/plain'))
} catch (e) {
return
}
if (!payload || !payload.sskcbh) return
const to = { rq: this.fmtDate(day.date), jc }
if (payload.rq === to.rq && payload.jc === to.jc) return
this.submitting = true
moveLesson({ sskcbh: payload.sskcbh, from: { rq: payload.rq, jc: payload.jc }, to })
.then(res => {
const data = (res && res.data) || {}
if (data.warning) {
this.$message.warning('已移动,但 ' + data.warning)
} else {
this.$message.success('课次已移动')
}
this.loadView()
})
.catch(err => {
this.$message.error((err && err.message) || '移动失败')
})
.finally(() => {
this.submitting = false
})
},
/* ==================== 导出 / 打印(E4 ==================== */
handleExport() {
if (!this.xydxqbh) return
exportSchedulingView(this.xydxqbh)
.then(blob => {
saveAs(blob, `排课窗口_${this.xydxqbh}.xlsx`)
})
.catch(() => this.$message.error('导出失败'))
},
handlePrint() {
window.print()
},
/** ⑨ 当前格子编排信息 */
focusCell(rq, jc) {
this.focusedCell = { rq: this.fmtDate(rq), jc }
},
/** 双击格子:反选课程 + 回填预排信息(手册 12.3.2) */
dblclickCell(day, jc) {
const cell = this.cellOf(day, jc)
if (cell && cell.unavailable) {
this.$message.warning(cell.reason || '该节次不可排课')
return
}
this.focusCell(day.date, jc)
const lessons = (cell && cell.lessons) || []
if (!lessons.length) return
const first = lessons[0]
const course = this.courses.find(c => c.sskcbh === first.sskcbh)
if (course) {
this.currentCourse = course
}
// 回填:教学方法 / 教学内容 / 教学要点 / 节次调节 / 场地 / 教员 / 教学班次
this.arrangeForm.jxnr = first.jxnr || ''
this.arrangeForm.jxyd = first.jxyd || ''
this.arrangeForm.jxff = first.jxff || '理论讲授'
this.arrangeForm.jcdj = first.jcdj || 0
this.arrangeForm.jxbzbz = first.jxbzbz || ''
this.arrangeForm.ycxx = first.ycxx || ''
this.arrangeForm.jsbhs = (first.jsbhs || []).slice()
this.arrangeForm.jsbh = this.arrangeForm.jsbhs[0] || ''
this.arrangeForm.jybh = first.jybh || ''
this.arrangeForm.xydbhs = (first.xydbhs || []).slice()
this.arrangeTeams = (first.xydbhs || []).map(bh => ({ xydbh: bh, xydmc: this.teamNameOf(bh) }))
this.$message.success(`已回填「${first.kcmc || ''}」的预排信息`)
},
teamNameOf(xydbh) {
const t = (this.currentCourse && this.currentCourse.teams || []).find(x => x.xydbh === xydbh)
if (t) return t.xydmc
const c = this.candidateTeams.find(x => x.xydbh === xydbh)
return c ? c.xydmc : xydbh
},
/** 快捷键:S 安排 / R 删除(手册 12.3.4 / 12.3.5 */
onKeydown(ev) {
const tag = (ev.target && ev.target.tagName) || ''
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || (ev.target && ev.target.isContentEditable)
if (typing) return
this.handleHotkey(ev)
},
onGlobalKeydown(ev) {
const tag = (ev.target && ev.target.tagName) || ''
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || (ev.target && ev.target.isContentEditable)
if (typing || ev.ctrlKey || ev.metaKey) return
// 时间区未获得焦点时使用 Alt+S / Alt+R
if (ev.altKey) this.handleHotkey(ev)
},
handleHotkey(ev) {
const key = (ev.key || '').toLowerCase()
if (key === 's') {
ev.preventDefault()
if (this.canArrange) this.openArrangeForm()
} else if (key === 'r') {
ev.preventDefault()
if (this.canOperate && this.pickedLessonCount) this.handleDeleteCells()
}
},
/* ==================== 排课 ==================== */
openArrangeForm() {
if (!this.canArrange) {
this.$message.warning('请先选择课程,并在右侧格子中选择要安排的节次')
return
}
this.formTab = 'subject'
this.arrangeForm.jxnr = ''
this.arrangeForm.jxyd = ''
this.arrangeForm.jxff = '理论讲授'
this.arrangeForm.jcdj = 0
this.arrangeForm.jxbzbz = ''
this.arrangeForm.ycxx = ''
this.arrangeForm.jybh = ''
this.arrangeForm.fzjybhs = []
// 场地默认课程默认场地;教学班次默认课程全部合班班次
this.arrangeForm.jsbhs = this.currentCourse.jsbh ? [this.currentCourse.jsbh] : []
this.arrangeForm.jsbh = this.arrangeForm.jsbhs[0] || ''
this.carDepartTime = ''
this.carDepartPlace = ''
this.carReturnTime = ''
this.carDest = ''
this.fzjyToAdd = ''
this.roomToAdd = ''
this.teamToAdd = ''
this.loadCourseTeams()
this.formVisible = true
},
/** 教学班次:当前 + 候选 */
loadCourseTeams() {
const course = this.currentCourse
this.arrangeTeams = (course.teams || []).map(t => ({ xydbh: t.xydbh, xydmc: t.xydmc, rs: null }))
this.candidateTeams = []
if (!course || !course.sskcbh) return
getCourseTeams(course.sskcbh).then(res => {
const data = (res && res.data) || {}
if (data.current && data.current.length) this.arrangeTeams = data.current
this.candidateTeams = data.candidates || []
}).catch(() => {})
},
addTeam() {
if (!this.teamToAdd) {
this.$message.warning('请选择要加入的教学班次')
return
}
const t = this.candidateTeams.find(x => x.xydbh === this.teamToAdd)
this.arrangeTeams.push({ xydbh: this.teamToAdd, xydmc: t ? t.xydmc : this.teamToAdd, rs: t ? t.rs : null })
this.teamToAdd = ''
},
removeTeam(t) {
if (this.arrangeTeams.length <= 1) {
this.$message.warning('至少保留一个教学班次')
return
}
const idx = this.arrangeTeams.indexOf(t)
if (idx >= 0) this.arrangeTeams.splice(idx, 1)
},
addFzjy() {
const bh = (this.fzjyToAdd || '').trim()
if (!bh) {
this.$message.warning('请输入辅讲教员工号')
return
}
if (!this.arrangeForm.fzjybhs.includes(bh)) this.arrangeForm.fzjybhs.push(bh)
this.fzjyToAdd = ''
},
addRoom() {
const bh = (this.roomToAdd || '').trim()
if (!bh) {
this.$message.warning('请选择或输入教学场地')
return
}
if (!this.arrangeForm.jsbhs.includes(bh)) this.arrangeForm.jsbhs.push(bh)
this.arrangeForm.jsbh = this.arrangeForm.jsbhs[0]
this.roomToAdd = ''
},
/** 用车信息四要素合成文本(字段 ycxx 存文本) */
composeCar() {
const parts = []
if (this.carDepartTime) parts.push('出发 ' + this.carDepartTime)
if (this.carDepartPlace) parts.push('自 ' + this.carDepartPlace)
if (this.carReturnTime) parts.push('返回 ' + this.carReturnTime)
if (this.carDest) parts.push('目的地 ' + this.carDest)
return parts.join('')
},
submitArrange() {
if (!this.selectedCells.length) {
this.$message.warning('未选择节次')
return
}
if (!this.arrangeTeams.length) {
this.$message.warning('请至少保留一个教学班次')
return
}
if (!this.arrangeForm.jsbhs.length && !(this.currentCourse && this.currentCourse.jsbh)) {
this.$message.warning('课程未设置默认场地,请先指定教学场地')
return
}
this.submitting = true
const payload = Object.assign({}, this.arrangeForm, {
xydxqbh: this.xydxqbh,
sskcbh: this.currentCourse.sskcbh,
ycxx: this.composeCar(),
jsbh: this.arrangeForm.jsbhs[0] || '',
xydbhs: this.arrangeTeams.map(t => t.xydbh),
cells: this.selectedCells.slice()
})
arrangeLessons(payload).then(res => {
this.submitting = false
this.formVisible = false
const data = (res && res.data) || {}
const arranged = data.arranged || 0
const ignored = data.ignored || []
const warnings = data.warnings || []
this.$message.success(`已排入 ${arranged} 个节次`)
if (ignored.length) {
this.$alert(
ignored.map(i => `${i.rq}${i.jc} 节:${i.reason}`).join('<br/>'),
`${ignored.length} 个节次被忽略`,
{ dangerouslyUseHTMLString: true }
)
}
if (warnings.length) {
this.$notify({
title: `软提示(${warnings.length} 条)`,
message: warnings.map(i => `${i.rq}${i.jc} 节:${i.reason}`).join('<br/>'),
dangerouslyUseHTMLString: true,
type: 'warning',
duration: 8000
})
}
this.loadView()
}).catch(() => {
this.submitting = false
})
},
handleDeleteCells() {
if (!this.pickedLessonCount) {
this.$message.warning('所选节次中没有当前课程的课次')
return
}
this.$confirm(`确定删除当前课程在所选节次上的课次吗?`, '提示', { type: 'warning' }).then(() => {
this.submitting = true
deleteLessonCells({
sskcbh: this.currentCourse.sskcbh,
cells: this.selectedCells.slice()
}).then(res => {
this.submitting = false
this.$message.success(`已删除 ${(res && res.data) || 0} 个课次`)
this.loadView()
}).catch(() => {
this.submitting = false
})
}).catch(() => {})
},
handleClearCourse() {
this.$confirm(`确定清空課程「${this.currentCourse.kcmc || ''}」的全部节次吗?课程仍保留在列表。`, '提示', { type: 'warning' }).then(() => {
clearCourseLessons(this.currentCourse.sskcbh).then(res => {
this.$message.success(`已清空 ${(res && res.data) || 0} 个课次`)
this.loadView()
})
}).catch(() => {})
},
handleDeleteCourse() {
this.$confirm(`确定删除运行课程「${this.currentCourse.kcmc || ''}」吗?课次与列表项一起删除,编制侧任务保留。`, '警告', { type: 'warning' }).then(() => {
deleteRunningCourse(this.currentCourse.sskcbh).then(res => {
this.$message.success(`已删除课程,影响 ${(res && res.data) || 0} 条记录`)
this.currentCourse = null
this.loadView()
})
}).catch(() => {})
},
handleOpenLogs() {
listSchedulingLogs(this.currentCourse.sskcbh).then(res => {
this.logs = (res && res.data) || []
this.logsVisible = true
}).catch(() => {
this.logs = []
this.logsVisible = true
})
},
/* ==================== 编辑课程教学任务信息(手册 12.3.1 ==================== */
openCourseEdit(course) {
const c = course || this.currentCourse
if (!c) {
this.$message.warning('请先选择课程')
return
}
if (!c.editable) {
this.$message.warning('当前账号无该课程的信息修改权限')
return
}
this.courseForm = {
sskcbh: c.sskcbh,
kcmc: c.kcmc,
jc: c.jc || '',
xs: c.xs || 0,
zks: c.zks || 0,
jybh: c.jybh || '',
jsbh: c.jsbh || ''
}
this.courseFormVisible = true
},
submitCourseEdit() {
if (!this.courseForm.sskcbh) return
this.submitting = true
updateSchedulingCourse({
sskcbh: this.courseForm.sskcbh,
xs: this.courseForm.xs,
zks: this.courseForm.zks,
jc: this.courseForm.jc,
jybh: this.courseForm.jybh,
jsbh: this.courseForm.jsbh
}).then(() => {
this.submitting = false
this.courseFormVisible = false
this.$message.success('课程信息已保存')
this.loadView()
}).catch(() => {
this.submitting = false
})
} }
}
</script>
<style lang="scss" scoped>
.scheduling-page {
padding: 16px;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: var(--edu-color-text, #172b25);
}
.head-tools {
display: flex;
align-items: center;
gap: 8px;
}
.empty-tip {
padding: 18px;
text-align: center;
color: var(--edu-color-text-muted, #7a8a85);
font-size: 13px;
}
.semester-card {
margin-bottom: 12px;
}
.semester-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.semester-name {
font-size: 15px;
font-weight: 600;
color: var(--edu-color-text, #172b25);
}
.semester-meta {
display: flex;
gap: 18px;
font-size: 13px;
color: var(--edu-color-text-secondary, #52645f);
}
.panel-card {
min-height: 420px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.panel-tip {
font-size: 12px;
font-weight: 400;
color: var(--edu-color-text-muted, #7a8a85);
}
/* ==================== 课程列表 ==================== */
.course-list {
max-height: 560px;
overflow-y: auto;
}
.course-item {
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid var(--edu-color-border, #dce6e2);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
background: #fff;
&:hover {
border-color: var(--edu-color-primary, #0b6b55);
}
&.is-active {
border-color: var(--edu-color-primary, #0b6b55);
background: var(--edu-color-primary-faint, #f3f8f6);
box-shadow: 0 0 0 1px var(--edu-color-primary, #0b6b55) inset;
}
&.is-full .ci-name {
color: var(--edu-color-primary, #0b6b55);
}
&.is-readonly {
opacity: 0.72;
}
}
.ci-line1 {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.ci-name {
font-weight: 600;
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--edu-color-text, #172b25);
}
.ci-line2 {
display: flex;
gap: 14px;
font-size: 12px;
color: var(--edu-color-text-secondary, #52645f);
margin-bottom: 6px;
}
.ci-line3 {
display: flex;
align-items: center;
gap: 8px;
}
.ci-line3 .el-progress {
flex: 1;
}
.ci-hours {
font-size: 12px;
color: var(--edu-color-text-muted, #7a8a85);
white-space: nowrap;
}
.ci-line4 {
margin-top: 4px;
font-size: 11px;
color: var(--edu-color-text-muted, #9aa8a4);
font-family: Consolas, Monaco, monospace;
}
/* ==================== 周次网格 ==================== */
.week-nav {
display: flex;
align-items: center;
gap: 10px;
}
.week-text {
font-size: 13px;
color: var(--edu-color-text, #172b25);
}
.week-range {
margin-left: 6px;
color: var(--edu-color-text-muted, #7a8a85);
font-size: 12px;
}
.legend {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--edu-color-text-secondary, #52645f);
font-weight: 400;
}
.legend .lg {
display: inline-flex;
align-items: center;
gap: 4px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 2px;
display: inline-block;
}
.dot-free { background: #fff; border: 1px solid var(--edu-color-border-strong, #c8d8d2); }
.dot-picked { background: #dff0ea; border: 1px solid var(--edu-color-primary, #0b6b55); }
.dot-lesson { background: #12604c; }
.dot-warn { background: #fdf3d8; border: 1px solid #c69b3c; }
.dot-block { background: #eceeed; border: 1px solid #c8cecc; }
.timetable-wrap {
overflow-x: auto;
user-select: none;
}
.timetable {
min-width: 940px;
border: 1px solid var(--edu-color-border, #dce6e2);
border-radius: 6px;
overflow: hidden;
}
.tb-row {
display: grid;
grid-template-columns: 70px repeat(7, minmax(118px, 1fr));
}
.tb-cell {
border-right: 1px solid #eef3f1;
border-bottom: 1px solid #eef3f1;
min-height: 76px;
padding: 4px;
font-size: 12px;
position: relative;
}
.tb-head .tb-cell {
min-height: 46px;
background: var(--edu-color-primary-faint, #f3f8f6);
text-align: center;
padding: 6px 2px;
}
.tb-corner,
.tb-jc {
display: flex;
align-items: center;
justify-content: center;
background: var(--edu-color-primary-faint, #f3f8f6);
font-weight: 600;
color: var(--edu-color-text-secondary, #52645f);
min-height: 76px;
}
.tb-jc {
min-height: 76px;
}
.day-name {
font-weight: 600;
color: var(--edu-color-text, #172b25);
}
.day-date {
color: var(--edu-color-text-muted, #7a8a85);
font-size: 11px;
}
.tb-day.day-off {
background: #f2f4f3;
color: #999;
}
.tb-slot {
background: #fff;
cursor: pointer;
transition: background 0.12s;
&:hover {
background: #f7fbf9;
}
&.day-off {
background: #f7f8f8;
}
&.is-block {
background: repeating-linear-gradient(45deg, #f2f4f3, #f2f4f3 6px, #eaeceb 6px, #eaeceb 12px);
cursor: not-allowed;
}
&.is-warn {
background: #fdf6ec;
box-shadow: inset 0 0 0 1px #e6a23c;
}
&.is-picked {
background: #dff0ea;
box-shadow: inset 0 0 0 2px var(--edu-color-primary, #0b6b55);
}
}
.slot-picked {
color: var(--edu-color-primary, #0b6b55);
font-weight: 600;
text-align: center;
padding-top: 18px;
}
.slot-block {
color: #96a19d;
font-size: 11px;
line-height: 1.3;
padding: 2px;
}
.lesson {
background: var(--edu-color-primary-soft, #e8f3ef);
border-left: 3px solid var(--edu-color-primary, #0b6b55);
border-radius: 3px;
padding: 3px 5px;
margin-bottom: 3px;
&.is-current {
background: #d5ece3;
border-left-color: #063f34;
}
}
.lesson-name {
font-weight: 600;
color: var(--edu-color-text, #172b25);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lesson-meta {
color: var(--edu-color-text-secondary, #52645f);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ==================== 操作条 ==================== */
.op-bar {
position: sticky;
bottom: 0;
z-index: 5;
margin-top: 12px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border: 1px solid var(--edu-color-border, #dce6e2);
border-radius: 6px;
box-shadow: 0 -2px 8px rgba(23, 43, 37, 0.06);
}
.op-left {
display: flex;
gap: 20px;
font-size: 13px;
color: var(--edu-color-text-secondary, #52645f);
}
.op-course b,
.op-picked b {
color: var(--edu-color-primary, #0b6b55);
}
.op-right {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.form-summary {
display: flex;
gap: 18px;
margin-bottom: 12px;
padding: 8px 12px;
background: var(--edu-color-primary-faint, #f3f8f6);
border-radius: 4px;
font-size: 13px;
color: var(--edu-color-text-secondary, #52645f);
}
.form-summary b {
color: var(--edu-color-primary, #0b6b55);
}
.ml8 {
margin-left: 8px;
}
</style>
<!-- ==================== 交互增强样式冲突显示 / 全学期视图 / 格子信息 / 面板 ==================== -->
<style lang="scss" scoped>
.grid-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--edu-color-border, #e2e9e6);
}
.grid-controls .ctl-label {
font-size: 12px;
color: var(--edu-color-text-secondary, #52645f);
margin-right: 2px;
}
.grid-controls .ctl-group ::v-deep .el-checkbox-button__inner {
padding: 5px 9px;
font-size: 12px;
}
/* 冲突格着色(深红=硬冲突,与冲突明细页色板统一;橙色仅用于软提示) */
.tb-slot.is-conflict {
background: #fde2e2;
box-shadow: inset 0 0 0 2px #f56c6c;
}
.dot-cur { background: #f7c9d8; border: 1px solid #c2477a; }
.dot-conflict { background: #fde2e2; border: 1px solid #f56c6c; }
/* 全学期视图:每周一段,可跨周框选 */
.timetable.is-overview .tb-weekline {
margin: 10px 0 4px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
color: var(--edu-color-text-secondary, #52645f);
background: var(--edu-color-primary-faint, #f3f8f6);
border-radius: 3px;
}
.timetable.is-overview .tb-slot {
min-height: 26px;
padding: 1px 2px;
}
.timetable.is-overview .lesson-name {
font-size: 11px;
}
.timetable.is-overview .lesson-meta {
display: none;
}
.timetable-wrap:focus {
outline: 2px solid var(--edu-color-primary, #0b6b55);
outline-offset: -2px;
}
/* 当前格子信息 + 选择区域统计 */
.cell-info-bar {
display: flex;
gap: 12px;
margin-top: 10px;
border-top: 1px dashed var(--edu-color-border, #e2e9e6);
padding-top: 10px;
}
.cell-info-bar .ci-block {
flex: 1;
min-width: 0;
background: var(--edu-color-primary-faint, #f7faf9);
border: 1px solid var(--edu-color-border, #e2e9e6);
border-radius: 4px;
padding: 8px 10px;
font-size: 12px;
max-height: 168px;
overflow: auto;
}
.cell-info-bar .ci-title {
font-weight: 600;
color: var(--edu-color-text, #172b25);
margin-bottom: 6px;
}
.cell-info-bar .ci-empty {
color: var(--edu-color-text-secondary, #8a9a95);
}
.cell-info-bar .ci-row {
display: flex;
gap: 6px;
line-height: 1.7;
}
.cell-info-bar .ci-k {
flex: 0 0 66px;
color: var(--edu-color-text-secondary, #52645f);
}
.cell-info-bar .ci-danger { color: #c0392b; }
.cell-info-bar .ci-warn { color: #b7791f; }
.cell-info-bar .ci-lesson {
border-left: 2px solid var(--edu-color-primary, #0b6b55);
padding-left: 6px;
margin-bottom: 2px;
}
/* 操作条提示 */
.op-hint {
font-size: 12px;
color: var(--edu-color-text-secondary, #8a9a95);
margin-left: 8px;
}
/* 排课弹窗 */
.advice-bar {
margin-bottom: 10px;
padding: 6px 10px;
font-size: 12px;
color: #8a5a00;
background: #fdf3d8;
border-left: 3px solid #c69b3c;
border-radius: 3px;
}
.form-tabs ::v-deep .el-tabs__header {
margin-bottom: 10px;
}
.pane-tip {
font-size: 12px;
color: var(--edu-color-text-secondary, #52645f);
background: var(--edu-color-primary-faint, #f7faf9);
border-radius: 3px;
padding: 6px 8px;
margin-bottom: 8px;
}
.pane-list {
border: 1px solid var(--edu-color-border, #e2e9e6);
border-radius: 4px;
min-height: 60px;
max-height: 190px;
overflow: auto;
}
.pane-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
font-size: 12px;
border-bottom: 1px dashed var(--edu-color-border, #eef2f0);
}
.pane-item:last-child { border-bottom: none; }
.pane-item .pi-main {
flex: 1;
min-width: 0;
color: var(--edu-color-text, #172b25);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pane-item .pi-sub {
color: var(--edu-color-text-secondary, #8a9a95);
font-size: 11px;
}
.pane-add {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.car-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.ci-merge,
.ci-advice {
color: var(--edu-color-text-secondary, #52645f);
}
.ci-advice {
color: #8a5a00;
}
.ci-edit {
float: right;
padding: 0;
}
</style>
<style lang="scss">
/* E4:拖拽调课与打印(非 scoped,拖拽/打印需命中 body 级) */
.tb-slot.drop-hover {
outline: 2px dashed var(--edu-color-primary, #2f7d5c);
outline-offset: -2px;
background: var(--edu-color-primary-faint, #eef7f2);
}
.lesson.is-draggable {
cursor: grab;
}
.lesson.is-draggable:active {
cursor: grabbing;
}
@media print {
.page-head,
.op-bar,
.cell-info-bar,
.grid-controls,
.week-nav .el-radio-group,
.week-nav .el-button,
.el-dialog__wrapper,
.v-modal {
display: none !important;
}
.timetable-wrap {
max-height: none !important;
overflow: visible !important;
}
}
</style>