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;
course?: CourseResponse;
class?: ClassResponse;
/** 排课选择的课程类型(子课程模式时用于直接进入对应子课程) */
lessonType?: string;
}

View File

@ -49,10 +49,10 @@
</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-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)"
class="clickable-step" />
</a-steps>
@ -218,6 +218,19 @@
<!-- 右侧工具面板 -->
<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 class="panel-header">
@ -412,6 +425,7 @@ import {
import { message, Modal } from 'ant-design-vue';
import * as teacherApi from '@/api/teacher';
import FilePreviewModal from '@/components/FilePreviewModal.vue';
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
const router = useRouter();
const route = useRoute();
@ -434,6 +448,8 @@ let timerInterval: number | null = null;
const course = ref<any>({});
const classInfo = ref<any>({});
const lessons = ref<any[]>([]);
/** 排课选择的课程类型(子课程模式:仅展示该子课程,子课程结束即上课结束) */
const scheduleLessonType = ref<string | undefined>(undefined);
const studentEvaluation = ref({
overall: 0,
@ -449,8 +465,37 @@ const lessonRecord = ref({
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(() => {
@ -467,9 +512,9 @@ const stepProgressPercent = computed(() => {
//
const hasPreviousLesson = computed(() => currentLessonIndex.value > 0);
//
//
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;
return currentStepIndex.value >= totalSteps - 1;
});
@ -648,6 +693,9 @@ const loadLessonData = async () => {
course.value = data.course || {};
classInfo.value = data.class || {};
//
scheduleLessonType.value = data.lessonType || undefined;
//
// 使使
if (data.lessonCourses && data.lessonCourses.length > 0) {
@ -701,51 +749,62 @@ 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 {
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({
title: '检测到上次上课进度',
content: `上次上课到:${getProgressDescription(progress)},是否继续?`,
okText: '继续上课',
cancelText: '重新开始',
onOk: () => {
//
if (isSub && progressIsForMatched && progress.currentStepId !== undefined) {
//
currentLessonIndex.value = 0;
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 (lessonIndex >= 0) currentLessonIndex.value = lessonIndex;
}
}
if (progress.currentStepId !== undefined) {
currentStepIndex.value = progress.currentStepId;
if (progress.currentStepId !== undefined) currentStepIndex.value = progress.currentStepId;
}
},
onCancel: () => {
//
clearProgress();
},
onCancel: () => clearProgress(),
});
}
} catch (progressError) {
//
console.log('No saved progress found');
}
// URL
// URL
if (matchedLessons.length === 0) {
const queryLessonIndex = route.query.lessonIndex ? parseInt(route.query.lessonIndex as string) : 0;
if (queryLessonIndex >= 0 && queryLessonIndex < lessons.value.length) {
currentLessonIndex.value = queryLessonIndex;
}
// URL
const queryStepIndex = route.query.stepIndex ? parseInt(route.query.stepIndex as string) : 0;
const totalSteps = lessons.value[currentLessonIndex.value]?.steps?.length || 0;
if (queryStepIndex >= 0 && queryStepIndex < totalSteps) {
currentStepIndex.value = queryStepIndex;
}
}
//
startTimer();
@ -770,10 +829,11 @@ const getProgressDescription = (progress: any): string => {
//
const saveProgress = async () => {
try {
const list = displayLessons.value;
await teacherApi.saveLessonProgress(lessonId.value, {
lessonIds: lessons.value.map((l) => l.id),
completedLessonIds: lessons.value.slice(0, currentLessonIndex.value).map((l) => l.id),
currentLessonId: lessons.value[currentLessonIndex.value]?.id,
lessonIds: list.map((l) => l.id),
completedLessonIds: list.slice(0, currentLessonIndex.value).map((l) => l.id),
currentLessonId: list[currentLessonIndex.value]?.id,
currentStepId: currentStepIndex.value,
progressData: {
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 {
.panel-header {
background: #FFF5EB;

View File

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

View File

@ -72,6 +72,7 @@ public class TeacherLessonController {
@Operation(summary = "根据 ID 获取课时(含课程、班级,供上课页面使用)")
@GetMapping("/{id}")
public Result<LessonDetailResponse> getLesson(@PathVariable Long id) {
Long tenantId = SecurityUtils.getCurrentTenantId();
Lesson lesson = lessonService.getLessonById(id);
LessonResponse lessonVo = lessonMapper.toVO(lesson);
enrichWithCourseAndClass(lessonVo);
@ -93,6 +94,17 @@ public class TeacherLessonController {
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);
}

View File

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