feat: 根据排课lessonType直接进入子课程,子课程结束即上课结束;右侧添加课程类型展示

- 后端:LessonDetailResponse 新增 lessonType,从 SchedulePlan 读取
- 前端:根据 lessonType 直接进入对应子课程,子课程结束即上课结束
- 前端:右侧面板课程资源上方添加课程类型标签展示

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-20 10:56:41 +08:00
parent 5a05af18dd
commit f90037dd17
5 changed files with 164 additions and 104 deletions

View File

@ -16,4 +16,6 @@ export interface LessonDetailResponse {
lesson?: LessonResponse; lesson?: LessonResponse;
course?: CourseResponse; course?: CourseResponse;
class?: ClassResponse; class?: ClassResponse;
/** 排课选择的课程类型(子课程模式时用于直接进入对应子课程) */
lessonType?: string;
} }

View File

@ -49,10 +49,10 @@
</div> </div>
</div> </div>
<!-- 课程进度条多课程时显示 --> <!-- 课程进度条多课程时显示子课程模式不显示 -->
<div v-if="lessons.length > 1" class="course-progress-bar"> <div v-if="displayLessons.length > 1" class="course-progress-bar">
<a-steps :current="currentLessonIndex" size="small" class="course-steps"> <a-steps :current="currentLessonIndex" size="small" class="course-steps">
<a-step v-for="(lesson, index) in lessons" :key="lesson.id" :title="getLessonShortName(lesson)" <a-step v-for="(lesson, index) in displayLessons" :key="lesson.id" :title="getLessonShortName(lesson)"
:status="getLessonStatus(index)" :disabled="index > currentLessonIndex" @click="handleLessonClick(index)" :status="getLessonStatus(index)" :disabled="index > currentLessonIndex" @click="handleLessonClick(index)"
class="clickable-step" /> class="clickable-step" />
</a-steps> </a-steps>
@ -218,6 +218,19 @@
<!-- 右侧工具面板 --> <!-- 右侧工具面板 -->
<div class="tool-panel"> <div class="tool-panel">
<!-- 课程类型 -->
<div v-if="currentLesson?.lessonType" class="panel-card lesson-type-card">
<div class="panel-header">
<BookOutlined />
<span>课程类型</span>
</div>
<div class="panel-body">
<a-tag size="large" class="lesson-type-tag" :style="getLessonTagStyle(currentLesson.lessonType)">
{{ getLessonTypeName(currentLesson.lessonType) }}
</a-tag>
</div>
</div>
<!-- 课程核心资源 --> <!-- 课程核心资源 -->
<div v-if="hasCourseResources" class="panel-card materials-card"> <div v-if="hasCourseResources" class="panel-card materials-card">
<div class="panel-header"> <div class="panel-header">
@ -412,6 +425,7 @@ import {
import { message, Modal } from 'ant-design-vue'; import { message, Modal } from 'ant-design-vue';
import * as teacherApi from '@/api/teacher'; import * as teacherApi from '@/api/teacher';
import FilePreviewModal from '@/components/FilePreviewModal.vue'; import FilePreviewModal from '@/components/FilePreviewModal.vue';
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@ -434,6 +448,8 @@ let timerInterval: number | null = null;
const course = ref<any>({}); const course = ref<any>({});
const classInfo = ref<any>({}); const classInfo = ref<any>({});
const lessons = ref<any[]>([]); const lessons = ref<any[]>([]);
/** 排课选择的课程类型(子课程模式:仅展示该子课程,子课程结束即上课结束) */
const scheduleLessonType = ref<string | undefined>(undefined);
const studentEvaluation = ref({ const studentEvaluation = ref({
overall: 0, overall: 0,
@ -449,8 +465,37 @@ const lessonRecord = ref({
completionNote: '', completionNote: '',
}); });
/** 判断排课 lessonType 与课程 lessonType 是否匹配(兼容 INTRODUCTION/INTRO、LANGUAGE/DOMAIN_LANGUAGE 等变体) */
const lessonTypeMatches = (scheduleType: string, lessonType: string): boolean => {
if (!scheduleType || !lessonType) return false;
const s = scheduleType.toUpperCase();
const l = lessonType.toUpperCase();
if (s === l) return true;
const pairs: [string, string][] = [
['INTRODUCTION', 'INTRO'],
['LANGUAGE', 'DOMAIN_LANGUAGE'],
['HEALTH', 'DOMAIN_HEALTH'],
['SCIENCE', 'DOMAIN_SCIENCE'],
['SOCIAL', 'DOMAIN_SOCIAL'],
['SOCIETY', 'DOMAIN_SOCIAL'],
['ART', 'DOMAIN_ART'],
];
for (const [a, b] of pairs) {
if ((s === a || s === b) && (l === a || l === b)) return true;
}
return false;
};
/** 展示的课程列表:子课程模式时仅包含排课选中的子课程,否则为全部 */
const displayLessons = computed(() => {
const type = scheduleLessonType.value;
if (!type || lessons.value.length === 0) return lessons.value;
const matched = lessons.value.filter((l) => lessonTypeMatches(type, l.lessonType || ''));
return matched.length > 0 ? matched : lessons.value;
});
// //
const currentLesson = computed(() => lessons.value[currentLessonIndex.value] || null); const currentLesson = computed(() => displayLessons.value[currentLessonIndex.value] || null);
// //
const currentStep = computed(() => { const currentStep = computed(() => {
@ -467,9 +512,9 @@ const stepProgressPercent = computed(() => {
// //
const hasPreviousLesson = computed(() => currentLessonIndex.value > 0); const hasPreviousLesson = computed(() => currentLessonIndex.value > 0);
// //
const isLastStepOfLastLesson = computed(() => { const isLastStepOfLastLesson = computed(() => {
if (currentLessonIndex.value < lessons.value.length - 1) return false; if (currentLessonIndex.value < displayLessons.value.length - 1) return false;
const totalSteps = currentLesson.value?.steps?.length || 1; const totalSteps = currentLesson.value?.steps?.length || 1;
return currentStepIndex.value >= totalSteps - 1; return currentStepIndex.value >= totalSteps - 1;
}); });
@ -648,6 +693,9 @@ const loadLessonData = async () => {
course.value = data.course || {}; course.value = data.course || {};
classInfo.value = data.class || {}; classInfo.value = data.class || {};
//
scheduleLessonType.value = data.lessonType || undefined;
// //
// 使使 // 使使
if (data.lessonCourses && data.lessonCourses.length > 0) { if (data.lessonCourses && data.lessonCourses.length > 0) {
@ -701,50 +749,61 @@ const loadLessonData = async () => {
}]; }];
} }
// lessonType URL
const matchedLessons = scheduleLessonType.value
? lessons.value.filter((l) => lessonTypeMatches(scheduleLessonType.value!, l.lessonType || ''))
: [];
if (matchedLessons.length > 0) {
currentLessonIndex.value = 0;
currentStepIndex.value = 0;
}
// //
try { try {
const progress = await teacherApi.getLessonProgress(lessonId.value); const progress = await teacherApi.getLessonProgress(lessonId.value);
if (progress && (progress.currentLessonId || progress.currentStepId)) { if (progress && (progress.currentLessonId !== undefined || progress.currentStepId !== undefined)) {
// const isSub = matchedLessons.length > 0;
const matchedLesson = matchedLessons[0];
const progressIsForMatched = isSub && progress.currentLessonId !== undefined
&& matchedLesson && progress.currentLessonId === matchedLesson.id;
Modal.confirm({ Modal.confirm({
title: '检测到上次上课进度', title: '检测到上次上课进度',
content: `上次上课到:${getProgressDescription(progress)},是否继续?`, content: `上次上课到:${getProgressDescription(progress)},是否继续?`,
okText: '继续上课', okText: '继续上课',
cancelText: '重新开始', cancelText: '重新开始',
onOk: () => { onOk: () => {
// if (isSub && progressIsForMatched && progress.currentStepId !== undefined) {
if (progress.currentLessonId !== undefined) { //
const lessonIndex = lessons.value.findIndex((l) => l.id === progress.currentLessonId); currentLessonIndex.value = 0;
if (lessonIndex >= 0) {
currentLessonIndex.value = lessonIndex;
}
}
if (progress.currentStepId !== undefined) {
currentStepIndex.value = progress.currentStepId; currentStepIndex.value = progress.currentStepId;
} else if (!isSub) {
//
if (progress.currentLessonId !== undefined) {
const lessonIndex = lessons.value.findIndex((l) => l.id === progress.currentLessonId);
if (lessonIndex >= 0) currentLessonIndex.value = lessonIndex;
}
if (progress.currentStepId !== undefined) currentStepIndex.value = progress.currentStepId;
} }
}, },
onCancel: () => { onCancel: () => clearProgress(),
//
clearProgress();
},
}); });
} }
} catch (progressError) { } catch (progressError) {
//
console.log('No saved progress found'); console.log('No saved progress found');
} }
// URL // URL
const queryLessonIndex = route.query.lessonIndex ? parseInt(route.query.lessonIndex as string) : 0; if (matchedLessons.length === 0) {
if (queryLessonIndex >= 0 && queryLessonIndex < lessons.value.length) { const queryLessonIndex = route.query.lessonIndex ? parseInt(route.query.lessonIndex as string) : 0;
currentLessonIndex.value = queryLessonIndex; if (queryLessonIndex >= 0 && queryLessonIndex < lessons.value.length) {
} currentLessonIndex.value = queryLessonIndex;
}
// URL const queryStepIndex = route.query.stepIndex ? parseInt(route.query.stepIndex as string) : 0;
const queryStepIndex = route.query.stepIndex ? parseInt(route.query.stepIndex as string) : 0; const totalSteps = lessons.value[currentLessonIndex.value]?.steps?.length || 0;
const totalSteps = lessons.value[currentLessonIndex.value]?.steps?.length || 0; if (queryStepIndex >= 0 && queryStepIndex < totalSteps) {
if (queryStepIndex >= 0 && queryStepIndex < totalSteps) { currentStepIndex.value = queryStepIndex;
currentStepIndex.value = queryStepIndex; }
} }
// //
@ -770,10 +829,11 @@ const getProgressDescription = (progress: any): string => {
// //
const saveProgress = async () => { const saveProgress = async () => {
try { try {
const list = displayLessons.value;
await teacherApi.saveLessonProgress(lessonId.value, { await teacherApi.saveLessonProgress(lessonId.value, {
lessonIds: lessons.value.map((l) => l.id), lessonIds: list.map((l) => l.id),
completedLessonIds: lessons.value.slice(0, currentLessonIndex.value).map((l) => l.id), completedLessonIds: list.slice(0, currentLessonIndex.value).map((l) => l.id),
currentLessonId: lessons.value[currentLessonIndex.value]?.id, currentLessonId: list[currentLessonIndex.value]?.id,
currentStepId: currentStepIndex.value, currentStepId: currentStepIndex.value,
progressData: { progressData: {
timerSeconds: timerSeconds.value, timerSeconds: timerSeconds.value,
@ -1559,6 +1619,19 @@ onUnmounted(() => {
} }
} }
.lesson-type-card {
.panel-header {
background: #F5F5F5;
color: #666;
}
.lesson-type-tag {
font-size: 14px;
padding: 6px 14px;
border-radius: 8px;
}
}
.materials-card { .materials-card {
.panel-header { .panel-header {
background: #FFF5EB; background: #FFF5EB;

View File

@ -4,7 +4,9 @@
<h2>我的课表</h2> <h2>我的课表</h2>
<a-space> <a-space>
<a-button type="primary" @click="showCreateModal"> <a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template> <template #icon>
<PlusOutlined />
</template>
预约上课 预约上课
</a-button> </a-button>
</a-space> </a-space>
@ -12,7 +14,9 @@
<!-- 今日课程 --> <!-- 今日课程 -->
<div class="today-section" v-if="todaySchedules.length > 0"> <div class="today-section" v-if="todaySchedules.length > 0">
<h3><CalendarOutlined /> 今日课程</h3> <h3>
<CalendarOutlined /> 今日课程
</h3>
<a-row :gutter="16"> <a-row :gutter="16">
<a-col :span="6" v-for="schedule in todaySchedules" :key="schedule.id"> <a-col :span="6" v-for="schedule in todaySchedules" :key="schedule.id">
<a-card size="small" class="today-card" :class="{ 'has-lesson': schedule.hasLesson }"> <a-card size="small" class="today-card" :class="{ 'has-lesson': schedule.hasLesson }">
@ -21,31 +25,20 @@
</template> </template>
<div class="course-name">{{ schedule.courseName || schedule.coursePackageName || '-' }}</div> <div class="course-name">{{ schedule.courseName || schedule.coursePackageName || '-' }}</div>
<div class="class-name">{{ schedule.className || '-' }}</div> <div class="class-name">{{ schedule.className || '-' }}</div>
<a-tag v-if="schedule.lessonType" size="small" class="today-lesson-type" :style="getLessonTagStyle(schedule.lessonType)"> <a-tag v-if="schedule.lessonType" size="small" class="today-lesson-type"
:style="getLessonTagStyle(schedule.lessonType)">
{{ getLessonTypeName(schedule.lessonType) }} {{ getLessonTypeName(schedule.lessonType) }}
</a-tag> </a-tag>
<div class="card-actions"> <div class="card-actions">
<a-button <a-button v-if="schedule.hasLesson && schedule.lessonStatus === 'PLANNED'" type="primary" size="small"
v-if="schedule.hasLesson && schedule.lessonStatus === 'PLANNED'" @click="goToLesson(schedule.lessonId!)">
type="primary"
size="small"
@click="goToLesson(schedule.lessonId!)"
>
开始上课 开始上课
</a-button> </a-button>
<a-button <a-button v-else-if="schedule.hasLesson && schedule.lessonStatus === 'IN_PROGRESS'" type="primary"
v-else-if="schedule.hasLesson && schedule.lessonStatus === 'IN_PROGRESS'" size="small" @click="goToLesson(schedule.lessonId!)">
type="primary"
size="small"
@click="goToLesson(schedule.lessonId!)"
>
继续上课 继续上课
</a-button> </a-button>
<a-button <a-button v-else size="small" @click="handleStartLessonFromSchedule(schedule)">
v-else
size="small"
@click="handleStartLessonFromSchedule(schedule)"
>
创建课堂 创建课堂
</a-button> </a-button>
</div> </div>
@ -58,13 +51,17 @@
<div class="week-navigation"> <div class="week-navigation">
<a-space> <a-space>
<a-button @click="goToPrevWeek"> <a-button @click="goToPrevWeek">
<template #icon><LeftOutlined /></template> <template #icon>
<LeftOutlined />
</template>
上一周 上一周
</a-button> </a-button>
<a-button @click="goToCurrentWeek">本周</a-button> <a-button @click="goToCurrentWeek">本周</a-button>
<a-button @click="goToNextWeek"> <a-button @click="goToNextWeek">
下一周 下一周
<template #icon><RightOutlined /></template> <template #icon>
<RightOutlined />
</template>
</a-button> </a-button>
<span class="week-range">{{ weekRangeText }}</span> <span class="week-range">{{ weekRangeText }}</span>
</a-space> </a-space>
@ -74,12 +71,7 @@
<div class="timetable-container"> <div class="timetable-container">
<a-spin :spinning="loading"> <a-spin :spinning="loading">
<div class="timetable-header"> <div class="timetable-header">
<div <div v-for="day in weekDays" :key="day.date" class="day-header" :class="{ 'is-today': day.isToday }">
v-for="day in weekDays"
:key="day.date"
class="day-header"
:class="{ 'is-today': day.isToday }"
>
<div class="day-name">{{ day.dayName }}</div> <div class="day-name">{{ day.dayName }}</div>
<div class="day-date">{{ day.dateDisplay }}</div> <div class="day-date">{{ day.dateDisplay }}</div>
</div> </div>
@ -87,27 +79,17 @@
<div class="timetable-body"> <div class="timetable-body">
<div class="timetable-grid"> <div class="timetable-grid">
<div <div v-for="day in weekDays" :key="day.date" class="day-column" :class="{ 'is-today': day.isToday }">
v-for="day in weekDays" <div v-for="schedule in day.schedules" :key="schedule.id" class="schedule-card" :class="{
:key="day.date" 'school-schedule': schedule.source === 'SCHOOL',
class="day-column" 'teacher-schedule': schedule.source === 'TEACHER',
:class="{ 'is-today': day.isToday }" 'has-lesson': schedule.hasLesson,
> }" @click="showScheduleDetail(schedule)">
<div
v-for="schedule in day.schedules"
:key="schedule.id"
class="schedule-card"
:class="{
'school-schedule': schedule.source === 'SCHOOL',
'teacher-schedule': schedule.source === 'TEACHER',
'has-lesson': schedule.hasLesson,
}"
@click="showScheduleDetail(schedule)"
>
<div class="schedule-time">{{ schedule.scheduledTime || '待定' }}</div> <div class="schedule-time">{{ schedule.scheduledTime || '待定' }}</div>
<div class="schedule-course">{{ schedule.courseName || schedule.coursePackageName || '-' }}</div> <div class="schedule-course">{{ schedule.courseName || schedule.coursePackageName || '-' }}</div>
<div class="schedule-class">{{ schedule.className || '-' }}</div> <div class="schedule-class">{{ schedule.className || '-' }}</div>
<a-tag v-if="schedule.lessonType" size="small" class="schedule-lesson-type" :style="getLessonTagStyle(schedule.lessonType)"> <a-tag v-if="schedule.lessonType" size="small" class="schedule-lesson-type"
:style="getLessonTagStyle(schedule.lessonType)">
{{ getLessonTypeName(schedule.lessonType) }} {{ getLessonTypeName(schedule.lessonType) }}
</a-tag> </a-tag>
<div class="schedule-source"> <div class="schedule-source">
@ -133,18 +115,15 @@
<TeacherCreateScheduleModal ref="createScheduleModalRef" @success="onCreateScheduleSuccess" /> <TeacherCreateScheduleModal ref="createScheduleModalRef" @success="onCreateScheduleSuccess" />
<!-- 排课详情弹窗 --> <!-- 排课详情弹窗 -->
<a-modal <a-modal v-model:open="detailVisible" title="排课详情" :footer="null" width="500px">
v-model:open="detailVisible"
title="排课详情"
:footer="null"
width="500px"
>
<template v-if="selectedSchedule"> <template v-if="selectedSchedule">
<a-descriptions :column="1" bordered> <a-descriptions :column="1" bordered>
<a-descriptions-item label="班级">{{ selectedSchedule.className || '-' }}</a-descriptions-item> <a-descriptions-item label="班级">{{ selectedSchedule.className || '-' }}</a-descriptions-item>
<a-descriptions-item label="课程">{{ selectedSchedule.courseName || selectedSchedule.coursePackageName || '-' }}</a-descriptions-item> <a-descriptions-item label="课程">{{ selectedSchedule.courseName || selectedSchedule.coursePackageName || '-'
}}</a-descriptions-item>
<a-descriptions-item label="课程类型"> <a-descriptions-item label="课程类型">
<a-tag v-if="selectedSchedule.lessonType" size="small" :style="getLessonTagStyle(selectedSchedule.lessonType)"> <a-tag v-if="selectedSchedule.lessonType" size="small"
:style="getLessonTagStyle(selectedSchedule.lessonType)">
{{ getLessonTypeName(selectedSchedule.lessonType) }} {{ getLessonTypeName(selectedSchedule.lessonType) }}
</a-tag> </a-tag>
<span v-else>-</span> <span v-else>-</span>
@ -167,25 +146,16 @@
</a-descriptions> </a-descriptions>
<div style="margin-top: 16px; text-align: right;"> <div style="margin-top: 16px; text-align: right;">
<a-space> <a-space>
<a-button <a-button v-if="selectedSchedule.hasLesson && selectedSchedule.lessonId" type="primary"
v-if="selectedSchedule.hasLesson && selectedSchedule.lessonId" @click="goToLesson(selectedSchedule.lessonId)">
type="primary"
@click="goToLesson(selectedSchedule.lessonId)"
>
{{ selectedSchedule.lessonStatus === 'IN_PROGRESS' ? '继续上课' : '查看课堂' }} {{ selectedSchedule.lessonStatus === 'IN_PROGRESS' ? '继续上课' : '查看课堂' }}
</a-button> </a-button>
<a-button <a-button v-else-if="selectedSchedule.status === 'ACTIVE'" type="primary"
v-else-if="selectedSchedule.status === 'ACTIVE'" @click="handleStartLessonFromSchedule(selectedSchedule)">
type="primary"
@click="handleStartLessonFromSchedule(selectedSchedule)"
>
开始上课 开始上课
</a-button> </a-button>
<a-popconfirm <a-popconfirm v-if="selectedSchedule.source === 'TEACHER' && selectedSchedule.status === 'ACTIVE'"
v-if="selectedSchedule.source === 'TEACHER' && selectedSchedule.status === 'ACTIVE'" title="确定要取消此预约吗?" @confirm="handleCancelSchedule(selectedSchedule.id)">
title="确定要取消此预约吗?"
@confirm="handleCancelSchedule(selectedSchedule.id)"
>
<a-button danger>取消预约</a-button> <a-button danger>取消预约</a-button>
</a-popconfirm> </a-popconfirm>
</a-space> </a-space>

View File

@ -72,6 +72,7 @@ public class TeacherLessonController {
@Operation(summary = "根据 ID 获取课时(含课程、班级,供上课页面使用)") @Operation(summary = "根据 ID 获取课时(含课程、班级,供上课页面使用)")
@GetMapping("/{id}") @GetMapping("/{id}")
public Result<LessonDetailResponse> getLesson(@PathVariable Long id) { public Result<LessonDetailResponse> getLesson(@PathVariable Long id) {
Long tenantId = SecurityUtils.getCurrentTenantId();
Lesson lesson = lessonService.getLessonById(id); Lesson lesson = lessonService.getLessonById(id);
LessonResponse lessonVo = lessonMapper.toVO(lesson); LessonResponse lessonVo = lessonMapper.toVO(lesson);
enrichWithCourseAndClass(lessonVo); enrichWithCourseAndClass(lessonVo);
@ -93,6 +94,17 @@ public class TeacherLessonController {
detail.setClassInfo(classMapper.toVO(clazz)); detail.setClassInfo(classMapper.toVO(clazz));
} }
} }
// 从排课计划读取 lessonType供前端判断子课程模式并直接进入对应子课程
if (lesson.getSchedulePlanId() != null) {
try {
SchedulePlan schedule = schoolScheduleService.getScheduleById(lesson.getSchedulePlanId(), tenantId);
if (schedule != null && schedule.getLessonType() != null) {
detail.setLessonType(schedule.getLessonType());
}
} catch (Exception e) {
// 排课不存在或无权访问时忽略
}
}
return Result.success(detail); return Result.success(detail);
} }

View File

@ -20,4 +20,7 @@ public class LessonDetailResponse {
@JsonProperty("class") @JsonProperty("class")
@Schema(description = "班级信息") @Schema(description = "班级信息")
private ClassResponse classInfo; private ClassResponse classInfo;
@Schema(description = "排课选择的课程类型(子课程模式时用于直接进入对应子课程)")
private String lessonType;
} }