Files
railwaysystem/build_ui/src/views/train/plan/components/TimetablePanel.vue
T

1405 lines
48 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="timetable-panel">
<!-- 头部操作与统计条 -->
<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
<div>
<span style="font-weight: bold; font-size: 14px; margin-right: 15px;">本期完整课程表</span>
<el-tag size="small" type="info">总课次:{{ allLessons.length }} 节</el-tag>
<span v-if="timetableData.startDate && timetableData.endDate" style="margin-left: 12px; font-size: 12px; color: #909399;">
排课周期:{{ timetableData.startDate }} ~ {{ timetableData.endDate }}
</span>
</div>
<div>
<el-radio-group v-model="viewMode" size="mini" style="margin-right: 10px;">
<el-radio-button label="week">学校周课表</el-radio-button>
<el-radio-button label="timeline">按日日程</el-radio-button>
<el-radio-button label="table">课次明细</el-radio-button>
</el-radio-group>
<el-button
v-hasPermi="['train:lesson:add']"
type="primary"
icon="el-icon-plus"
size="mini"
:disabled="isPlanLocked"
@click="handleOpenAddLesson"
>新增课次排课</el-button>
<el-button
icon="el-icon-refresh"
size="mini"
@click="loadTimetable"
>刷新课表</el-button>
</div>
</div>
<!-- 视图零:学校周课表视图 (UC-11 学校风格课表展示) -->
<div v-if="viewMode === 'week'" v-loading="loading" class="school-timetable-view">
<!-- 周切换条 -->
<div class="week-nav-bar">
<el-button-group size="mini">
<el-button icon="el-icon-arrow-left" @click="handlePrevWeek">上一周</el-button>
<el-button @click="handleCurrentOrFirstWeek">本周/首周</el-button>
<el-button @click="handleNextWeek">下一周<i class="el-icon-arrow-right el-icon--right" /></el-button>
</el-button-group>
<span class="week-title-display">
<i class="el-icon-date" style="color: #409EFF; margin-right: 5px;" />
{{ currentWeekDisplayTitle }}
</span>
<span style="font-size: 12px; color: #909399;">
本周课次:{{ currentWeekLessonCount }} 节
</span>
</div>
<!-- 课表格子结构 -->
<div class="school-grid-container">
<table class="school-grid-table">
<thead>
<tr>
<th class="time-col-header">时段 / 星期</th>
<th
v-for="day in currentWeekDays"
:key="day.dateStr"
:class="['day-col-header', { 'is-today': isToday(day.dateStr) }]"
>
<div class="header-day-label">{{ day.label }}</div>
<div class="header-date-label">{{ day.displayDate }}</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="period in periodList" :key="period.key">
<td class="period-title-cell">
<div class="period-name">{{ period.label }}</div>
<div class="period-time-range">{{ period.timeRange }}</div>
</td>
<td
v-for="day in currentWeekDays"
:key="`${day.dateStr}_${period.key}`"
class="timetable-cell"
>
<!-- 格子中的课次卡片列表 -->
<div
v-for="lesson in getLessonsInCell(day.dateStr, period.key)"
:key="lesson.lessonId"
class="school-lesson-card"
>
<div class="slc-time-tag">
<i class="el-icon-time" />
{{ formatTime(lesson.startTime) }} - {{ formatTime(lesson.endTime) }}
</div>
<div class="slc-course-title" :title="lesson.courseName">
{{ lesson.courseName }}
</div>
<div class="slc-meta">
<span class="meta-row">
<i class="el-icon-user" /> {{ lesson.teacherName || '教师待定' }}
</span>
<span class="meta-row">
<i class="el-icon-location-outline" />
{{ lesson.venueName || lesson.classRoomName || lesson.siteName || '地点待定' }}
</span>
<span v-if="lesson.boardSn" class="meta-row" style="color: #67C23A;">
<i class="el-icon-monitor" /> {{ lesson.boardSn }}
</span>
</div>
<div class="slc-card-actions">
<el-button
v-hasPermi="['train:attendance:list']"
size="mini"
type="text"
icon="el-icon-date"
@click.stop="handleOpenAttendance(lesson)"
>考勤</el-button>
<el-button
v-hasPermi="['train:lesson:edit']"
size="mini"
type="text"
icon="el-icon-edit"
:disabled="isPlanLocked"
@click.stop="handleEditLesson(lesson)"
>修改</el-button>
<el-button
v-hasPermi="['train:lesson:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="isPlanLocked"
@click.stop="handleRemoveLesson(lesson)"
>删除</el-button>
</div>
</div>
<!-- 空格子快速排课入口 -->
<div
v-if="getLessonsInCell(day.dateStr, period.key).length === 0 && !isPlanLocked"
class="empty-cell-placeholder"
@click="handleQuickAddLesson(day.dateStr, period.key)"
>
<i class="el-icon-plus" /> 排课
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 视图一:按日日程视图 -->
<div v-if="viewMode === 'timeline'" v-loading="loading" class="timeline-view">
<div v-if="!timetableData.days || timetableData.days.length === 0" class="empty-tip">
<el-empty description="暂未编排任何课次,请点击右上角【新增课次排课】" />
</div>
<div v-else class="day-cards">
<el-card
v-for="day in timetableData.days"
:key="day.date"
class="day-card"
shadow="hover"
>
<div slot="header" class="day-card-header">
<span style="font-weight: bold; font-size: 14px; color: #303133;">
<i class="el-icon-date" style="margin-right: 6px; color: #409EFF;" />
{{ day.date }}
<el-tag size="mini" type="info" style="margin-left: 8px;">{{ formatDayOfWeek(day.dayOfWeek) }}</el-tag>
</span>
<span style="font-size: 12px; color: #909399;">
共 {{ (day.lessons && day.lessons.length) || 0 }} 节课
</span>
</div>
<div v-if="!day.lessons || day.lessons.length === 0" style="color: #c0c4cc; font-size: 13px; padding: 10px 0;">
本日无课程安排
</div>
<div v-else class="lesson-items">
<div
v-for="lesson in day.lessons"
:key="lesson.lessonId"
class="lesson-item-box"
>
<div class="lesson-time">
<i class="el-icon-time" style="margin-right: 4px;" />
{{ formatTime(lesson.startTime) }} - {{ formatTime(lesson.endTime) }}
</div>
<div class="lesson-main">
<div class="lesson-title">
{{ lesson.courseName }}
</div>
<div class="lesson-meta">
<span class="meta-item">
<i class="el-icon-user" /> 教师:{{ lesson.teacherName || '-' }}
</span>
<span class="meta-item">
<i class="el-icon-location-outline" />
场地:
<el-tag size="mini" :type="lesson.venueType === 1 ? 'primary' : 'warning'">
{{ lesson.venueType === 1 ? '教室' : '实训场地' }}
</el-tag>
{{ lesson.venueName || lesson.classRoomName || lesson.siteName || '-' }}
</span>
<span v-if="lesson.boardSn" class="meta-item">
<i class="el-icon-monitor" /> 班牌:{{ lesson.boardSn }}
</span>
<span v-if="lesson.remark" class="meta-item" style="color: #909399;">
<i class="el-icon-info" /> {{ lesson.remark }}
</span>
</div>
</div>
<div class="lesson-actions">
<el-button
v-hasPermi="['train:attendance:list']"
size="mini"
type="text"
icon="el-icon-date"
@click="handleOpenAttendance(lesson)"
>考勤</el-button>
<el-button
v-hasPermi="['train:lesson:edit']"
size="mini"
type="text"
icon="el-icon-edit"
:disabled="isPlanLocked"
@click="handleEditLesson(lesson)"
>修改</el-button>
<el-button
v-hasPermi="['train:lesson:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="isPlanLocked"
@click="handleRemoveLesson(lesson)"
>删除</el-button>
</div>
</div>
</div>
</el-card>
</div>
</div>
<!-- 视图二:平面明细表格视图 -->
<div v-else v-loading="loading">
<el-table :data="allLessons" border style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="课程名称" prop="courseName" min-width="150" show-overflow-tooltip />
<el-table-column label="授课教师" prop="teacherName" width="100" align="center" />
<el-table-column label="开始时间" prop="startTime" width="165" align="center" />
<el-table-column label="结束时间" prop="endTime" width="165" align="center" />
<el-table-column label="场地类型" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.venueType === 1 ? 'primary' : 'warning'">
{{ scope.row.venueType === 1 ? '教室' : '实训场地' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上课地点" min-width="140" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.venueName || scope.row.classRoomName || scope.row.siteName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="电子班牌" prop="boardSn" width="120" align="center" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.boardSn || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="课次说明" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" align="center" width="180" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:attendance:list']"
size="mini"
type="text"
icon="el-icon-date"
@click="handleOpenAttendance(scope.row)"
>考勤</el-button>
<el-button
v-hasPermi="['train:lesson:edit']"
size="mini"
type="text"
icon="el-icon-edit"
:disabled="isPlanLocked"
@click="handleEditLesson(scope.row)"
>修改</el-button>
<el-button
v-hasPermi="['train:lesson:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="isPlanLocked"
@click="handleRemoveLesson(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 新增 / 编辑课次对话框 (UC-07, UC-08) -->
<el-dialog
:title="dialog.title"
:visible.sync="dialog.open"
width="680px"
append-to-body
@close="closeDialog"
>
<el-form ref="lessonForm" :model="lessonForm" :rules="rules" label-width="110px" size="small">
<el-form-item label="本期课程" prop="planCourseId">
<el-select
v-model="lessonForm.planCourseId"
placeholder="请选择本期配置的课程"
filterable
style="width: 100%"
>
<el-option
v-for="c in planCourseOptions"
:key="c.planCourseId"
:label="`${c.cName || '未命名'} (${c.cCode || '无编码'}) - ${c.cDuration != null ? c.cDuration : 0}学时`"
:value="c.planCourseId"
/>
</el-select>
</el-form-item>
<el-form-item label="授课教师" prop="tId">
<el-select
v-model="lessonForm.tId"
placeholder="请选择授课教师"
filterable
style="width: 100%"
@change="triggerAutoConflictCheck"
>
<el-option
v-for="t in teacherOptions"
:key="t.tId"
:label="teacherOptionLabel(t)"
:value="t.tId"
:disabled="!!t.disabled"
/>
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="课次开始" prop="startTime">
<el-date-picker
v-model="lessonForm.startTime"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择课次开始时间"
style="width: 100%"
@change="triggerAutoConflictCheck"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="课次结束" prop="endTime">
<el-date-picker
v-model="lessonForm.endTime"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择课次结束时间"
style="width: 100%"
@change="triggerAutoConflictCheck"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="场地类型" prop="venueType">
<el-radio-group v-model="lessonForm.venueType" @change="handleVenueTypeChange">
<el-radio :label="1">普通教学教室</el-radio>
<el-radio :label="2">专业实训场地</el-radio>
</el-radio-group>
</el-form-item>
<!-- 教室与场地二选一 -->
<el-form-item
v-if="lessonForm.venueType === 1"
label="教学教室"
prop="classRoomId"
>
<el-select
v-model="lessonForm.classRoomId"
placeholder="请选择普通教室"
filterable
style="width: 100%"
@change="triggerAutoConflictCheck"
>
<el-option
v-for="item in classroomOptions"
:key="item.classRoomId"
:label="`${item.roomName} (${item.roomLocation || ''}) [容量: ${item.capacity}人]`"
:value="item.classRoomId"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="lessonForm.venueType === 2"
label="实训场地"
prop="siteId"
>
<el-select
v-model="lessonForm.siteId"
placeholder="请选择专业实训场地"
filterable
style="width: 100%"
@change="triggerAutoConflictCheck"
>
<el-option
v-for="item in siteOptions"
:key="item.siteId"
:label="`${item.siteName} (${item.siteLocation || ''}) [容量: ${item.capacity}人]`"
:value="item.siteId"
/>
</el-select>
</el-form-item>
<el-form-item label="课次备注" prop="remark">
<el-input
v-model="lessonForm.remark"
type="textarea"
:rows="2"
maxlength="255"
show-word-limit
placeholder="请输入课次相关注意事项或说明(选填)"
/>
</el-form-item>
<!-- 冲突预检结果展示区域 (UC-10) -->
<div style="margin-left: 110px; margin-bottom: 15px;">
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<el-button
size="mini"
type="warning"
plain
icon="el-icon-connection"
:loading="conflictChecking"
@click="handleManualCheckConflict"
>排课冲突实时预检</el-button>
<span style="font-size: 12px; color: #909399; margin-left: 10px;">选择教师、场地与时间后可实时检测冲突</span>
</div>
<div v-if="conflictResult">
<el-alert
v-if="conflictResult.hasConflict"
:title="`排课冲突:${conflictResult.conflictMessage || conflictTypeLabel(conflictResult.conflictType)}`"
type="error"
:closable="false"
show-icon
>
<div v-if="conflictResult.conflictingLessons && conflictResult.conflictingLessons.length > 0" style="margin-top: 5px; font-size: 12px;">
冲突课次明细:
<div v-for="(item, idx) in conflictResult.conflictingLessons" :key="idx">
- {{ item.courseName || '课次' }} ({{ item.startTime }} ~ {{ item.endTime }})
</div>
</div>
</el-alert>
<el-alert
v-else
title="排课预检通过:教师、场地与班级时间段无重叠冲突"
type="success"
:closable="false"
show-icon
/>
</div>
</div>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button
type="primary"
:loading="dialog.submitting"
@click="submitLessonForm"
>保存课次</el-button>
<el-button @click="dialog.open = false">取 消</el-button>
</div>
</el-dialog>
<!-- 课次学员考勤名册对话框 (第四优先级) -->
<el-dialog
:title="`课次考勤名册 - ${attendanceDialog.lesson.courseName || ''}`"
:visible.sync="attendanceDialog.open"
width="900px"
append-to-body
>
<div v-loading="attendanceDialog.loading">
<!-- 课次基础概要 -->
<el-descriptions :column="3" border size="small" style="margin-bottom: 12px;">
<el-descriptions-item label="课程名称">{{ attendanceDialog.lesson.courseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="授课教师">{{ attendanceDialog.lesson.teacherName || '-' }}</el-descriptions-item>
<el-descriptions-item label="上课地点">{{ attendanceDialog.lesson.venueName || attendanceDialog.lesson.classRoomName || attendanceDialog.lesson.siteName || '-' }}</el-descriptions-item>
<el-descriptions-item label="课次时间" :span="2">
{{ attendanceDialog.lesson.startTime }} ~ {{ attendanceDialog.lesson.endTime }}
</el-descriptions-item>
<el-descriptions-item label="电子班牌">{{ attendanceDialog.lesson.boardSn || '无' }}</el-descriptions-item>
</el-descriptions>
<div style="margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
<div>
<span style="font-weight: bold; font-size: 13px;">应到学员考勤名册</span>
<el-tag size="mini" type="info" style="margin-left: 8px;">共 {{ attendanceDialog.rosterList.length }} 人</el-tag>
</div>
<div>
<el-button size="mini" icon="el-icon-refresh" @click="refreshAttendanceRoster">刷新名册</el-button>
<el-button
size="mini"
type="primary"
plain
icon="el-icon-s-promotion"
@click="goToFullCheckinPage"
>前往考勤中心</el-button>
</div>
</div>
<el-table :data="attendanceDialog.rosterList" border size="small" max-height="360" empty-text="当前课次暂无应到参训学员(学员可能尚未报到或未入班)">
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column label="学员姓名" prop="studentName" width="100" align="center" />
<el-table-column label="学员学号" prop="studentId" width="110" align="center" show-overflow-tooltip />
<el-table-column label="选派单位" prop="deptName" min-width="130" show-overflow-tooltip />
<el-table-column label="参训状态" width="85" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatEnrollmentStatusTag(scope.row.enrollmentStatus).type">
{{ formatEnrollmentStatusTag(scope.row.enrollmentStatus).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="考勤状态" width="85" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatAttendanceStatusTag(scope.row.attendanceStatus).type">
{{ formatAttendanceStatusTag(scope.row.attendanceStatus).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="签到时间" prop="signInTime" width="140" align="center">
<template slot-scope="scope">{{ scope.row.signInTime || '-' }}</template>
</el-table-column>
<el-table-column label="签退时间" prop="signOutTime" width="140" align="center">
<template slot-scope="scope">{{ scope.row.signOutTime || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="170" align="center" fixed="right">
<template slot-scope="scope">
<el-button
v-if="canRosterSignIn(scope.row)"
v-hasPermi="['train:attendance:checkin']"
size="mini"
type="text"
icon="el-icon-check"
@click="handleRosterSignIn(scope.row)"
>签到</el-button>
<el-button
v-if="canRosterMarkAbsence(scope.row)"
v-hasPermi="['train:attendance:edit']"
size="mini"
type="text"
icon="el-icon-close"
style="color: #E6A23C;"
@click="handleRosterMarkAbsence(scope.row)"
>缺勤</el-button>
<el-button
v-if="canRosterSignOut(scope.row)"
v-hasPermi="['train:attendance:checkin']"
size="mini"
type="text"
icon="el-icon-time"
@click="handleRosterSignOut(scope.row)"
>签退</el-button>
<el-button
v-if="canRosterAdjust(scope.row)"
v-hasPermi="['train:attendance:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleRosterAdjust(scope.row)"
>调整</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="attendanceDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
getPlanTimetable,
addLesson,
updateLesson,
removeLesson,
checkLessonConflict,
getSchedulingOptions
} from '@/api/train/timetable'
import { listPlanCourses } from '@/api/train/planCourse'
import {
getLessonRoster,
signInAttendance,
signOutAttendance,
adjustAttendance,
markAbsenceAttendance
} from '@/api/train/attendance'
export default {
name: 'TimetablePanel',
props: {
planId: {
type: [String, Number],
required: true
},
planStatus: {
type: Number,
default: 0
}
},
data() {
return {
loading: false,
viewMode: 'week', // 默认优先展示学校周课表视图,亦可切换 'timeline' 或 'table'
currentWeekAnchor: new Date(), // 当前周所锚定的日期对象
periodList: [
{ key: 'morning', label: '上午', timeRange: '08:00 - 12:00' },
{ key: 'afternoon', label: '下午', timeRange: '14:00 - 18:00' },
{ key: 'evening', label: '晚上', timeRange: '18:30 - 21:30' }
],
timetableData: {
planId: null,
planName: '',
startDate: '',
endDate: '',
days: []
},
// 基础下拉选项缓存
planCourseOptions: [],
teacherOptions: [],
classroomOptions: [],
siteOptions: [],
// 新增/编辑弹窗
dialog: {
open: false,
title: '新增课次排课',
isEdit: false,
submitting: false
},
lessonForm: {
lessonId: null,
planCourseId: null,
tId: null,
startTime: null,
endTime: null,
venueType: 1,
classRoomId: null,
siteId: null,
remark: ''
},
rules: {
planCourseId: [{ required: true, message: '请选择本期课程', trigger: 'change' }],
tId: [{ required: true, message: '请选择授课教师', trigger: 'change' }],
startTime: [{ required: true, message: '课次开始时间不能为空', trigger: 'change' }],
endTime: [{ required: true, message: '课次结束时间不能为空', trigger: 'change' }],
venueType: [{ required: true, message: '请选择场地类型', trigger: 'change' }],
classRoomId: [{ required: true, message: '请选择普通教室', trigger: 'change' }],
siteId: [{ required: true, message: '请选择实训场地', trigger: 'change' }]
},
// 冲突检测状态与结果
conflictChecking: false,
conflictResult: null,
// 课次考勤名册对话框
attendanceDialog: {
open: false,
loading: false,
lesson: {},
rosterList: []
}
}
},
computed: {
isPlanLocked() {
return this.planStatus === 2 || this.planStatus === 3
},
allLessons() {
if (!this.timetableData.days) return []
const list = []
this.timetableData.days.forEach(day => {
if (day.lessons && day.lessons.length) {
day.lessons.forEach(l => list.push(l))
}
})
return list
},
// 当前锚定周的周一到周日 7 天日期清单
currentWeekDays() {
const anchor = this.currentWeekAnchor ? new Date(this.currentWeekAnchor) : new Date()
// JS中 getDay() 0为周日,1为周一...6为周六
const day = anchor.getDay()
const diffToMonday = day === 0 ? -6 : 1 - day
const monday = new Date(anchor)
monday.setDate(anchor.getDate() + diffToMonday)
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const days = []
for (let i = 0; i < 7; i++) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const date = String(d.getDate()).padStart(2, '0')
const dateStr = `${year}-${month}-${date}`
days.push({
dateStr,
displayDate: `${month}-${date}`,
label: labels[i],
dayOfWeek: i + 1
})
}
return days
},
currentWeekDisplayTitle() {
if (!this.currentWeekDays || this.currentWeekDays.length === 0) return ''
const first = this.currentWeekDays[0].dateStr
const last = this.currentWeekDays[6].dateStr
return `${first} 至 ${last}`
},
currentWeekLessonCount() {
if (!this.currentWeekDays || !this.allLessons) return 0
const dates = new Set(this.currentWeekDays.map(d => d.dateStr))
return this.allLessons.filter(l => {
const d = (l.startTime || '').substring(0, 10)
return dates.has(d)
}).length
}
},
watch: {
planId: {
immediate: true,
handler(val) {
if (val) {
this.loadTimetable()
this.loadOptions()
}
}
}
},
methods: {
formatDayOfWeek(num) {
const map = { 1: '星期一', 2: '星期二', 3: '星期三', 4: '星期四', 5: '星期五', 6: '星期六', 7: '星期日' }
return map[num] || ''
},
formatTime(dateTimeStr) {
if (!dateTimeStr) return ''
if (dateTimeStr.includes(' ')) {
return dateTimeStr.split(' ')[1].substring(0, 5)
}
return dateTimeStr
},
conflictTypeLabel(type) {
const map = {
'NONE': '无冲突',
'TEACHER': '教师时间冲突',
'CLASSROOM': '教室时间占用冲突',
'SITE': '实训场地占用冲突',
'PLAN_OVERLAP': '本班课次时段重叠'
}
return map[type] || '存在排课冲突'
},
// ---- 加载课表 ----
loadTimetable() {
if (!this.planId) return
this.loading = true
getPlanTimetable(this.planId)
.then(res => {
this.timetableData = res.data || { days: [] }
})
.finally(() => {
this.loading = false
})
},
// ---- 预加载基础选项 ----
loadOptions() {
if (!this.planId) return
listPlanCourses(this.planId).then(res => {
this.planCourseOptions = res.data || []
})
// 排课候选统一走专用只读接口全量加载,不分页,避免候选被静默截断
getSchedulingOptions().then(res => {
const data = res.data || {}
this.teacherOptions = data.teachers || []
this.classroomOptions = data.classrooms || []
this.siteOptions = data.sites || []
})
},
handleVenueTypeChange(val) {
if (val === 1) {
this.lessonForm.siteId = null
} else {
this.lessonForm.classRoomId = null
}
this.triggerAutoConflictCheck()
},
// ---- 打开新增排课 ----
handleOpenAddLesson() {
this.dialog.isEdit = false
this.dialog.title = '新增课次排课'
this.resetLessonForm()
this.dialog.open = true
this.conflictResult = null
},
// 教师下拉展示文案:已停用的原授课教师仅保留姓名可见,明确标注停用状态
teacherOptionLabel(t) {
const name = t.name || t.tName || t.tId
if (t.disabled) {
return `${name}(已停用)`
}
return `${name} - ${t.workDept || t.orgName || '基地'}`
},
// 编辑历史课次时,原授课教师若已停用则不在候选列表中,
// 临时补一条禁用选项保证原教师名可见且不可被重新选中,绝不自动换人
ensureCurrentTeacherVisible(row) {
if (!row || !row.tId) return
if (this.teacherOptions.some(t => t.tId === row.tId)) return
this.teacherOptions = this.teacherOptions.concat([{
tId: row.tId,
tName: row.teacherName || row.tId,
disabled: true
}])
},
// ---- 打开修改排课 (不发明 lesson detail API,直接从已有VO回显) ----
handleEditLesson(row) {
this.ensureCurrentTeacherVisible(row)
this.dialog.isEdit = true
this.dialog.title = '编辑课次信息'
this.lessonForm = {
lessonId: row.lessonId,
planCourseId: row.planCourseId,
tId: row.tId,
startTime: row.startTime,
endTime: row.endTime,
venueType: row.venueType || 1,
classRoomId: row.classRoomId || null,
siteId: row.siteId || null,
remark: row.remark || ''
}
this.conflictResult = null
this.dialog.open = true
},
resetLessonForm() {
this.lessonForm = {
lessonId: null,
planCourseId: null,
tId: null,
startTime: null,
endTime: null,
venueType: 1,
classRoomId: null,
siteId: null,
remark: ''
}
if (this.$refs.lessonForm) {
this.$refs.lessonForm.clearValidate()
}
},
closeDialog() {
this.conflictResult = null
},
// ---- 冲突预检逻辑 (UC-10) ----
triggerAutoConflictCheck() {
// 当教师、场地与时间均选择后自动静默预检
const f = this.lessonForm
const hasVenue = (f.venueType === 1 && f.classRoomId) || (f.venueType === 2 && f.siteId)
if (f.tId && f.startTime && f.endTime && hasVenue) {
this.performConflictCheck(false)
}
},
handleManualCheckConflict() {
this.performConflictCheck(true)
},
performConflictCheck(showNoticeOnPass) {
const f = this.lessonForm
if (!f.startTime || !f.endTime) {
if (showNoticeOnPass) this.$modal.msgWarning('请先选择课次开始与结束时间')
return
}
if (f.startTime >= f.endTime) {
if (showNoticeOnPass) this.$modal.msgError('课次结束时间必须晚于开始时间')
return
}
const payload = {
planId: this.planId,
lessonId: f.lessonId,
planCourseId: f.planCourseId,
tId: f.tId,
startTime: f.startTime,
endTime: f.endTime,
venueType: f.venueType,
classRoomId: f.venueType === 1 ? f.classRoomId : null,
siteId: f.venueType === 2 ? f.siteId : null
}
this.conflictChecking = true
checkLessonConflict(payload)
.then(res => {
this.conflictResult = res.data || { hasConflict: false }
if (showNoticeOnPass && !this.conflictResult.hasConflict) {
this.$modal.msgSuccess('预检通过,未检测到任何冲突')
}
})
.finally(() => {
this.conflictChecking = false
})
},
// ---- 提交保存课次 (排课冲突告警确认) ----
submitLessonForm() {
this.$refs.lessonForm.validate(valid => {
if (!valid) return
if (this.lessonForm.startTime >= this.lessonForm.endTime) {
this.$modal.msgError('课次结束时间必须晚于开始时间')
return
}
// 构建保存 payload (严格对齐 LessonCreateDTO / LessonUpdateDTO,绝不含 force/override 字段)
const payload = {
planCourseId: this.lessonForm.planCourseId,
tId: this.lessonForm.tId,
startTime: this.lessonForm.startTime,
endTime: this.lessonForm.endTime,
venueType: this.lessonForm.venueType,
classRoomId: this.lessonForm.venueType === 1 ? this.lessonForm.classRoomId : null,
siteId: this.lessonForm.venueType === 2 ? this.lessonForm.siteId : null,
remark: this.lessonForm.remark
}
// 构建预检 DTO (编辑模式传 lessonId 排除自身,新增模式传 null)
const conflictCheckDTO = {
planId: this.planId,
lessonId: this.dialog.isEdit ? this.lessonForm.lessonId : null,
planCourseId: this.lessonForm.planCourseId,
tId: this.lessonForm.tId,
startTime: this.lessonForm.startTime,
endTime: this.lessonForm.endTime,
venueType: this.lessonForm.venueType,
classRoomId: this.lessonForm.venueType === 1 ? this.lessonForm.classRoomId : null,
siteId: this.lessonForm.venueType === 2 ? this.lessonForm.siteId : null
}
this.dialog.submitting = true
// 每次点击保存均重新发起冲突预检,避免依赖过期结果
checkLessonConflict(conflictCheckDTO)
.then(res => {
const checkData = res.data || { hasConflict: false }
this.conflictResult = checkData
if (checkData.hasConflict) {
const warningMsg = checkData.conflictMessage || this.conflictTypeLabel(checkData.conflictType)
this.$confirm(`检测到排课冲突:\n${warningMsg}\n\n是否仍然保存该课次?`, '排课冲突告警确认', {
confirmButtonText: '确认保存',
cancelButtonText: '返回调整',
type: 'warning'
}).then(() => {
this.executeSaveLesson(payload)
}).catch(() => {
this.dialog.submitting = false
})
} else {
this.executeSaveLesson(payload)
}
})
.catch(() => {
this.$modal.msgError('排课冲突检查失败,请稍后重试')
this.dialog.submitting = false
})
})
},
executeSaveLesson(payload) {
this.dialog.submitting = true
const request = this.dialog.isEdit
? updateLesson(this.lessonForm.lessonId, payload)
: addLesson(this.planId, payload)
request
.then(() => {
this.$modal.msgSuccess(this.dialog.isEdit ? '修改课次成功' : '新增课次成功')
this.dialog.open = false
this.loadTimetable()
})
.finally(() => {
this.dialog.submitting = false
})
},
// ---- 删除课次 ----
handleRemoveLesson(row) {
this.$modal.confirm(`确定要删除课次【${row.courseName} (${row.startTime})】吗?`).then(() => {
return removeLesson(row.lessonId)
}).then(() => {
this.$modal.msgSuccess('删除成功')
this.loadTimetable()
}).catch(() => {})
},
// ---- 学校周课表方法 (UC-11) ----
handlePrevWeek() {
const anchor = this.currentWeekAnchor ? new Date(this.currentWeekAnchor) : new Date()
anchor.setDate(anchor.getDate() - 7)
this.currentWeekAnchor = anchor
},
handleNextWeek() {
const anchor = this.currentWeekAnchor ? new Date(this.currentWeekAnchor) : new Date()
anchor.setDate(anchor.getDate() + 7)
this.currentWeekAnchor = anchor
},
handleCurrentOrFirstWeek() {
if (this.timetableData.startDate) {
const parsed = new Date(this.timetableData.startDate.replace(/-/g, '/'))
if (!isNaN(parsed.getTime())) {
this.currentWeekAnchor = parsed
return
}
}
this.currentWeekAnchor = new Date()
},
isToday(dateStr) {
if (!dateStr) return false
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
return dateStr === `${y}-${m}-${d}`
},
getLessonsInCell(dateStr, periodKey) {
if (!this.allLessons || !dateStr) return []
return this.allLessons.filter(lesson => {
const lessonDate = (lesson.startTime || '').substring(0, 10)
if (lessonDate !== dateStr) return false
const timePart = (lesson.startTime || '').split(' ')[1] || ''
const hour = parseInt(timePart.split(':')[0], 10)
if (isNaN(hour)) return false
if (periodKey === 'morning') return hour < 13
if (periodKey === 'afternoon') return hour >= 13 && hour < 18
if (periodKey === 'evening') return hour >= 18
return false
})
},
handleQuickAddLesson(dateStr, periodKey) {
this.dialog.isEdit = false
this.dialog.title = `新增课次排课 (${dateStr})`
this.resetLessonForm()
let startH = '09:00:00'
let endH = '11:30:00'
if (periodKey === 'afternoon') {
startH = '14:00:00'
endH = '16:30:00'
} else if (periodKey === 'evening') {
startH = '19:00:00'
endH = '21:00:00'
}
this.lessonForm.startTime = `${dateStr} ${startH}`
this.lessonForm.endTime = `${dateStr} ${endH}`
this.dialog.open = true
this.conflictResult = null
},
// 支持从“本期课程”外部直接调用排课并预选课程
openAddLessonForCourse(planCourseId) {
this.dialog.isEdit = false
this.dialog.title = '新增课次排课'
this.resetLessonForm()
this.lessonForm.planCourseId = planCourseId
this.dialog.open = true
this.conflictResult = null
},
// ---- 课次学员考勤名册与签到/缺勤 (第四优先级) ----
handleOpenAttendance(lesson) {
this.attendanceDialog.lesson = lesson || {}
this.attendanceDialog.open = true
this.refreshAttendanceRoster()
},
refreshAttendanceRoster() {
if (!this.attendanceDialog.lesson || !this.attendanceDialog.lesson.lessonId) return
this.attendanceDialog.loading = true
getLessonRoster(this.attendanceDialog.lesson.lessonId)
.then(res => {
this.attendanceDialog.rosterList = res.data || []
})
.finally(() => {
this.attendanceDialog.loading = false
})
},
goToFullCheckinPage() {
this.attendanceDialog.open = false
this.$router.push({
path: '/train/checkin',
query: {
planId: this.planId,
lessonId: this.attendanceDialog.lesson.lessonId
}
})
},
formatEnrollmentStatusTag(status) {
const map = {
0: { text: '待报到', type: 'info' },
1: { text: '已报到', type: 'success' },
2: { text: '请假', type: 'warning' },
3: { text: '放弃', type: 'danger' },
4: { text: '已完成', type: 'primary' }
}
return map[status] || { text: '未知', type: 'info' }
},
formatAttendanceStatusTag(status) {
const map = {
0: { text: '缺勤', type: 'danger' },
1: { text: '正常', type: 'success' },
2: { text: '迟到', type: 'warning' },
3: { text: '早退', type: 'warning' },
4: { text: '请假', type: 'info' }
}
if (status == null) {
return { text: '未签到', type: 'info' }
}
return map[status] || { text: '已记录', type: 'info' }
},
canRosterSignIn(row) {
return row.enrollmentStatus === 1 && (row.attendanceStatus == null || row.attendanceStatus === 0)
},
canRosterMarkAbsence(row) {
return row.attendanceStatus !== 0
},
canRosterSignOut(row) {
return (row.attendanceStatus === 1 || row.attendanceStatus === 2) && !row.signOutTime
},
canRosterAdjust(row) {
return row.checkinId != null
},
handleRosterSignIn(row) {
signInAttendance({
lessonId: this.attendanceDialog.lesson.lessonId,
enrollmentId: row.enrollmentId,
checkinWay: 4
}).then(() => {
this.$modal.msgSuccess(`学员【${row.studentName}】人工签到成功`)
this.refreshAttendanceRoster()
})
},
handleRosterSignOut(row) {
signOutAttendance({
lessonId: this.attendanceDialog.lesson.lessonId,
enrollmentId: row.enrollmentId
}).then(() => {
this.$modal.msgSuccess(`学员【${row.studentName}】签退成功`)
this.refreshAttendanceRoster()
})
},
handleRosterMarkAbsence(row) {
this.$prompt(`请输入学员【${row.studentName}】的缺勤原因:`, '课次缺勤登记', {
confirmButtonText: '确定登记',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: '缺勤原因不能为空'
}).then(({ value }) => {
return markAbsenceAttendance({
lessonId: this.attendanceDialog.lesson.lessonId,
enrollmentId: row.enrollmentId,
remark: value
})
}).then(() => {
this.$modal.msgSuccess(`已登记学员【${row.studentName}】缺勤`)
this.refreshAttendanceRoster()
}).catch(() => {})
},
handleRosterAdjust(row) {
this.$prompt(`请输入调整后的考勤状态(1:正常 2:迟到 3:早退 4:请假 0:缺勤):`, '调整考勤状态', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^[0-4]$/,
inputErrorMessage: '请输入0-4之间的数字状态码'
}).then(({ value }) => {
return adjustAttendance(row.checkinId, {
attendanceStatus: parseInt(value, 10),
remark: '管理员人工调整'
})
}).then(() => {
this.$modal.msgSuccess('考勤调整成功')
this.refreshAttendanceRoster()
}).catch(() => {})
}
}
}
</script>
<style scoped>
.timetable-panel {
padding: 5px 0;
}
.empty-tip {
padding: 40px 0;
}
.school-timetable-view {
margin-top: 5px;
}
.week-nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
background: #fdfdfd;
padding: 8px 12px;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.week-title-display {
font-weight: bold;
font-size: 14px;
color: #303133;
}
.school-grid-container {
overflow-x: auto;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fff;
}
.school-grid-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.school-grid-table th,
.school-grid-table td {
border: 1px solid #ebeef5;
padding: 6px;
vertical-align: top;
}
.time-col-header {
width: 100px;
background-color: #fafafa;
text-align: center;
font-size: 13px;
color: #606266;
font-weight: 600;
}
.day-col-header {
text-align: center;
background-color: #fafafa;
color: #303133;
padding: 8px 4px;
}
.day-col-header.is-today {
background-color: #ecf5ff;
border-top: 3px solid #409eff;
}
.header-day-label {
font-weight: bold;
font-size: 14px;
margin-bottom: 2px;
}
.header-date-label {
font-size: 12px;
color: #909399;
}
.period-title-cell {
background-color: #fafafa;
text-align: center;
vertical-align: middle !important;
}
.period-name {
font-weight: bold;
font-size: 14px;
color: #303133;
margin-bottom: 4px;
}
.period-time-range {
font-size: 11px;
color: #909399;
}
.timetable-cell {
min-height: 120px;
height: 135px;
position: relative;
background: #fff;
}
.school-lesson-card {
background: linear-gradient(135deg, #f0f7ff 0%, #e6f1fc 100%);
border: 1px solid #b3d8ff;
border-left: 4px solid #409eff;
border-radius: 4px;
padding: 6px 8px;
margin-bottom: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
}
.school-lesson-card:hover {
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.25);
transform: translateY(-1px);
}
.slc-time-tag {
font-size: 11px;
color: #409eff;
font-weight: 600;
margin-bottom: 3px;
}
.slc-course-title {
font-weight: bold;
font-size: 13px;
color: #1f2f3d;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slc-meta {
font-size: 11px;
color: #606266;
display: flex;
flex-direction: column;
gap: 2px;
}
.meta-row {
display: inline-flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slc-card-actions {
margin-top: 5px;
padding-top: 3px;
border-top: 1px dashed #dcdfe6;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.empty-cell-placeholder {
display: none;
height: 100%;
min-height: 80px;
align-items: center;
justify-content: center;
color: #c0c4cc;
font-size: 12px;
cursor: pointer;
border-radius: 4px;
border: 1px dashed #dcdfe6;
margin: 4px;
}
.timetable-cell:hover .empty-cell-placeholder {
display: flex;
background: #f9f9f9;
color: #409eff;
}
.day-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.day-card {
border-radius: 6px;
}
.day-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.lesson-items {
display: flex;
flex-direction: column;
gap: 10px;
}
.lesson-item-box {
display: flex;
align-items: center;
padding: 10px 14px;
background-color: #f8f9fa;
border-left: 4px solid #409eff;
border-radius: 4px;
}
.lesson-time {
width: 140px;
font-weight: bold;
color: #303133;
font-size: 13px;
flex-shrink: 0;
}
.lesson-main {
flex-grow: 1;
padding: 0 15px;
}
.lesson-title {
font-size: 14px;
font-weight: 500;
color: #1f2f3d;
margin-bottom: 4px;
}
.lesson-meta {
display: flex;
flex-wrap: wrap;
gap: 15px;
font-size: 12px;
color: #606266;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.lesson-actions {
flex-shrink: 0;
}
</style>