094245231c
2、已经限制教员排课的,教员仍然可以排课的问题修复
2090 lines
72 KiB
Vue
2090 lines
72 KiB
Vue
<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>
|