fix: 教师端课程与授课记录优化

- 修复 assessment_data JSON 字段:普通文本自动包装为有效 JSON
- 修复返回时页面 ID 丢失:校验无效 ID 并跳转,goBackToDetail 优先使用路由 ID
- 修复上课记录列表:getLessons 支持 pageNum、日期范围、状态映射,list 转 items
- 修复班级与课程取值:LessonResponse 增加 courseName/className,接口返回时自动填充
- 备课/详情页增加 ID 校验,防止跳转到 undefined

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-17 11:24:25 +08:00
parent e8b44b25e0
commit c8ecbe277c
8 changed files with 167 additions and 37 deletions

View File

@ -181,26 +181,52 @@ export interface StudentRecordDto {
notes?: string;
}
// 后端状态值: scheduled, in_progress, completed, cancelled
const STATUS_TO_BACKEND: Record<string, string> = {
PLANNED: 'scheduled',
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
};
// 获取授课记录列表
export function getLessons(params?: {
pageNum?: number;
page?: number;
pageSize?: number;
status?: string;
courseId?: number;
startDate?: string;
endDate?: string;
}): Promise<{
items: any[];
total: number;
page: number;
pageSize: number;
}> {
return http.get('/v1/teacher/lessons', {
params: {
pageNum: params?.pageNum,
pageSize: params?.pageSize,
status: params?.status,
startDate: params?.courseId, // 如果需要可以传其他参数
},
}) as any;
const pageNum = params?.pageNum ?? params?.page ?? 1;
const status = params?.status ? (STATUS_TO_BACKEND[params.status] || params.status) : undefined;
return http
.get<{ list?: any[]; records?: any[]; total?: number | string; pageNum?: number; pageSize?: number }>(
'/v1/teacher/lessons',
{
params: {
pageNum,
pageSize: params?.pageSize ?? 10,
status,
startDate: params?.startDate,
endDate: params?.endDate,
},
}
)
.then((res) => {
const list = res?.list ?? res?.records ?? [];
return {
items: Array.isArray(list) ? list : [],
total: typeof res?.total === 'string' ? parseInt(res.total, 10) || 0 : (res?.total ?? 0),
page: res?.pageNum ?? pageNum,
pageSize: res?.pageSize ?? 10,
};
});
}
// 获取单个授课记录详情id 使用 string 避免 Long 精度丢失)

View File

@ -17,7 +17,7 @@
<a-button @click="createSchoolVersion">
<CopyOutlined /> 创建校本版本
</a-button>
<a-button type="primary" @click="startPrepare">
<a-button type="primary" @click="startPrepare" :disabled="!course.id || loading">
<EditOutlined /> 开始备课
</a-button>
</div>
@ -281,7 +281,7 @@
<div class="lesson-section-title">教学环节 ({{ lesson.steps.length }})</div>
<div class="steps-timeline">
<div v-for="(step, index) in lesson.steps" :key="step.id || index" class="step-item">
<div class="step-dot">{{ index + 1 }}</div>
<div class="step-dot">{{ Number(index) + 1 }}</div>
<div class="step-content">
<div class="step-name">{{ step.name }}</div>
<div class="step-duration">{{ step.duration }}分钟</div>
@ -870,7 +870,12 @@ const createSchoolVersion = () => {
};
const startPrepare = () => {
router.push(`/teacher/courses/${course.value.id}/prepare`);
const id = route.params.id || course.value?.id;
if (!id || id === 'undefined') {
message.warning('课程信息未加载完成,请稍后再试');
return;
}
router.push(`/teacher/courses/${id}/prepare`);
};
const toggleFavorite = () => {

View File

@ -318,7 +318,12 @@ const viewCourseDetail = (course: any) => {
};
const prepareCourse = (course: any) => {
router.push(`/teacher/courses/${course.id}/prepare`);
const id = course?.id;
if (id == null || id === 'undefined') {
message.warning('课程信息异常,无法进入备课');
return;
}
router.push(`/teacher/courses/${id}/prepare`);
};
onMounted(() => {

View File

@ -121,7 +121,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
LeftOutlined, BookOutlined, ClockCircleOutlined, TagOutlined,
@ -210,8 +210,14 @@ const getFileUrl = (filePath: string | null | undefined): string => {
};
const loadCourseData = async () => {
courseId.value = (route.params.id as string) || '';
if (!courseId.value) return;
const id = route.params.id as string;
// ID /courses/undefined
if (!id || id === 'undefined' || id === 'null') {
message.warning('课程 ID 无效,已返回课程列表');
router.replace('/teacher/courses');
return;
}
courseId.value = id;
loading.value = true;
try {
@ -358,7 +364,7 @@ const handleSelectStep = (step: any) => {
selectedStep.value = step;
};
const handlePreviewResource = (type: string, resource: any) => {
const handlePreviewResource = (_type: string, resource: any) => {
previewFileUrl.value = resource.url;
previewFileName.value = resource.name || '资源文件';
previewModalVisible.value = true;
@ -452,12 +458,28 @@ const handleExit = () => {
};
const goBackToDetail = () => {
router.push(`/teacher/courses/${courseId.value}`);
// 使 ID courseId /courses/undefined
const id = route.params.id || courseId.value;
if (id && id !== 'undefined' && id !== 'null') {
router.push(`/teacher/courses/${id}`);
} else {
router.push('/teacher/courses');
}
};
onMounted(() => {
loadCourseData();
});
// A B
watch(
() => route.params.id,
(newId, oldId) => {
if (newId && newId !== oldId) {
loadCourseData();
}
}
);
</script>
<style scoped>

View File

@ -46,16 +46,16 @@
</div>
<div class="card-content">
<div class="card-header">
<h3 class="course-name">{{ lesson.course?.name || '未知课程' }}</h3>
<h3 class="course-name">{{ lesson.courseName || lesson.course?.name || lesson.title || '未知课程' }}</h3>
<span class="lesson-time">
<ClockCircleOutlined />
{{ formatDateTime(lesson.startDatetime || lesson.plannedDatetime) }}
{{ formatDateTime(lesson.startDatetime || lesson.plannedDatetime || (lesson.lessonDate && lesson.startTime ? `${lesson.lessonDate}T${lesson.startTime}` : null)) }}
</span>
</div>
<div class="card-body">
<div class="info-item">
<TeamOutlined />
<span>{{ lesson.class?.name || '未知班级' }}</span>
<span>{{ lesson.className || lesson.class?.name || '未知班级' }}</span>
</div>
<div class="info-item" v-if="lesson.actualDuration">
<FieldTimeOutlined />
@ -108,10 +108,10 @@
<div class="detail-content" v-if="selectedLesson">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="课程名称">
{{ selectedLesson.course?.name }}
{{ selectedLesson.courseName || selectedLesson.course?.name || selectedLesson.title || '-' }}
</a-descriptions-item>
<a-descriptions-item label="授课班级">
{{ selectedLesson.class?.name }}
{{ selectedLesson.className || selectedLesson.class?.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="课程状态">
<a-tag :color="getStatusColor(selectedLesson.status)">
@ -152,7 +152,7 @@
<!-- 操作按钮 -->
<div class="action-section">
<!-- 已计划状态 -->
<template v-if="selectedLesson.status === 'PLANNED'">
<template v-if="selectedLesson.status === 'PLANNED' || selectedLesson.status === 'scheduled'">
<a-button type="primary" block @click="startPlannedLesson" style="margin-bottom: 12px;">
开始上课
</a-button>
@ -226,12 +226,16 @@ const filters = reactive({
const detailDrawerVisible = ref(false);
const selectedLesson = ref<any>(null);
//
// scheduled/in_progress/completed/cancelled PLANNED/IN_PROGRESS
const statusMap: Record<string, { text: string; color: string; class: string }> = {
PLANNED: { text: '已计划', color: 'blue', class: 'status-planned' },
scheduled: { text: '已计划', color: 'blue', class: 'status-planned' },
IN_PROGRESS: { text: '进行中', color: 'orange', class: 'status-progress' },
in_progress: { text: '进行中', color: 'orange', class: 'status-progress' },
COMPLETED: { text: '已完成', color: 'green', class: 'status-completed' },
completed: { text: '已完成', color: 'green', class: 'status-completed' },
CANCELLED: { text: '已取消', color: 'default', class: 'status-cancelled' },
cancelled: { text: '已取消', color: 'default', class: 'status-cancelled' },
};
const getStatusText = (status: string) => statusMap[status]?.text || status;
@ -247,12 +251,16 @@ const loadLessons = async () => {
loading.value = true;
try {
const params: any = {
page: currentPage.value,
pageNum: currentPage.value,
pageSize: pageSize.value,
};
if (filters.status) {
params.status = filters.status;
}
if (filters.dateRange && filters.dateRange[0] && filters.dateRange[1]) {
params.startDate = filters.dateRange[0].format('YYYY-MM-DD');
params.endDate = filters.dateRange[1].format('YYYY-MM-DD');
}
const data = await teacherApi.getLessons(params);
lessons.value = data.items || [];
@ -288,8 +296,9 @@ const viewDetail = (lesson: any) => {
};
const goToPrepare = () => {
if (selectedLesson.value?.course?.id) {
router.push(`/teacher/courses/${selectedLesson.value.course.id}/prepare`);
const courseId = selectedLesson.value?.courseId ?? selectedLesson.value?.course?.id;
if (courseId) {
router.push(`/teacher/courses/${courseId}/prepare`);
detailDrawerVisible.value = false;
}
};
@ -309,8 +318,9 @@ const goToRecords = () => {
};
const goToCourseDetail = () => {
if (selectedLesson.value?.course?.id) {
router.push(`/teacher/courses/${selectedLesson.value.course.id}`);
const courseId = selectedLesson.value?.courseId ?? selectedLesson.value?.course?.id;
if (courseId) {
router.push(`/teacher/courses/${courseId}`);
detailDrawerVisible.value = false;
}
};

View File

@ -12,9 +12,13 @@ import com.reading.platform.dto.request.LessonUpdateRequest;
import com.reading.platform.dto.request.StudentRecordRequest;
import com.reading.platform.dto.response.LessonResponse;
import com.reading.platform.dto.response.StudentRecordResponse;
import com.reading.platform.entity.Clazz;
import com.reading.platform.entity.Course;
import com.reading.platform.entity.Lesson;
import com.reading.platform.entity.LessonFeedback;
import com.reading.platform.entity.StudentRecord;
import com.reading.platform.mapper.ClazzMapper;
import com.reading.platform.mapper.CourseMapper;
import com.reading.platform.service.LessonService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -35,13 +39,17 @@ public class TeacherLessonController {
private final LessonService lessonService;
private final LessonMapper lessonMapper;
private final StudentRecordMapper studentRecordMapper;
private final CourseMapper courseMapper;
private final ClazzMapper clazzMapper;
@Operation(summary = "Create lesson")
@PostMapping
public Result<LessonResponse> createLesson(@Valid @RequestBody LessonCreateRequest request) {
Long tenantId = SecurityUtils.getCurrentTenantId();
Lesson lesson = lessonService.createLesson(tenantId, request);
return Result.success(lessonMapper.toVO(lesson));
LessonResponse vo = lessonMapper.toVO(lesson);
enrichWithCourseAndClass(vo);
return Result.success(vo);
}
@Operation(summary = "Update lesson")
@ -55,7 +63,9 @@ public class TeacherLessonController {
@GetMapping("/{id}")
public Result<LessonResponse> getLesson(@PathVariable Long id) {
Lesson lesson = lessonService.getLessonById(id);
return Result.success(lessonMapper.toVO(lesson));
LessonResponse vo = lessonMapper.toVO(lesson);
enrichWithCourseAndClass(vo);
return Result.success(vo);
}
@Operation(summary = "Get my lessons")
@ -69,6 +79,7 @@ public class TeacherLessonController {
Long teacherId = SecurityUtils.getCurrentUserId();
Page<Lesson> page = lessonService.getTeacherLessons(teacherId, pageNum, pageSize, status, startDate, endDate);
List<LessonResponse> voList = lessonMapper.toVO(page.getRecords());
voList.forEach(this::enrichWithCourseAndClass);
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize()));
}
@ -98,7 +109,9 @@ public class TeacherLessonController {
public Result<List<LessonResponse>> getTodayLessons() {
Long tenantId = SecurityUtils.getCurrentTenantId();
List<Lesson> lessons = lessonService.getTodayLessons(tenantId);
return Result.success(lessonMapper.toVO(lessons));
List<LessonResponse> voList = lessonMapper.toVO(lessons);
voList.forEach(this::enrichWithCourseAndClass);
return Result.success(voList);
}
@Operation(summary = "Get student records")
@ -159,7 +172,24 @@ public class TeacherLessonController {
@GetMapping("/{id}/progress")
public Result<LessonResponse> getLessonProgress(@PathVariable Long id) {
Lesson lesson = lessonService.getLessonProgress(id);
return Result.success(lessonMapper.toVO(lesson));
LessonResponse vo = lessonMapper.toVO(lesson);
enrichWithCourseAndClass(vo);
return Result.success(vo);
}
/**
* LessonResponse 补充课程名称和班级名称
*/
private void enrichWithCourseAndClass(LessonResponse vo) {
if (vo == null) return;
if (vo.getCourseId() != null) {
Course course = courseMapper.selectById(vo.getCourseId());
vo.setCourseName(course != null ? course.getName() : null);
}
if (vo.getClassId() != null) {
Clazz clazz = clazzMapper.selectById(vo.getClassId());
vo.setClassName(clazz != null ? clazz.getName() : null);
}
}
}

View File

@ -29,6 +29,12 @@ public class LessonResponse {
@Schema(description = "班级 ID")
private Long classId;
@Schema(description = "课程名称(用于列表展示)")
private String courseName;
@Schema(description = "班级名称(用于列表展示)")
private String className;
@Schema(description = "教师 ID")
private Long teacherId;

View File

@ -1,5 +1,6 @@
package com.reading.platform.service;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.reading.platform.common.exception.BusinessException;
@ -112,8 +113,8 @@ public class CourseLessonService extends ServiceImpl<CourseLessonMapper, CourseL
lesson.setPreparation(preparation);
lesson.setExtension(extension);
lesson.setReflection(reflection);
// 处理空字符串为 null避免 MySQL JSON 字段错误
lesson.setAssessmentData(assessmentData == null || assessmentData.isEmpty() ? null : assessmentData);
// 确保 assessment_data 为有效 JSONMySQL JSON 列不接受纯文本
lesson.setAssessmentData(toValidJsonOrNull(assessmentData));
lesson.setUseTemplate(useTemplate);
lesson.setSortOrder(maxSortOrder + 1);
lesson.setCreatedAt(LocalDateTime.now());
@ -178,7 +179,7 @@ public class CourseLessonService extends ServiceImpl<CourseLessonMapper, CourseL
lesson.setReflection(reflection);
}
if (assessmentData != null) {
lesson.setAssessmentData(assessmentData);
lesson.setAssessmentData(toValidJsonOrNull(assessmentData));
}
if (useTemplate != null) {
lesson.setUseTemplate(useTemplate);
@ -355,6 +356,31 @@ public class CourseLessonService extends ServiceImpl<CourseLessonMapper, CourseL
log.info("教学环节重新排序成功lessonId={}", lessonId);
}
/**
* assessmentData 转为 MySQL JSON 列可接受的有效 JSON
* 空值返回 null已是有效 JSON 则原样返回否则包装为 JSON 字符串
*/
private String toValidJsonOrNull(String assessmentData) {
if (assessmentData == null || assessmentData.isEmpty()) {
return null;
}
String trimmed = assessmentData.trim();
if (trimmed.isEmpty()) {
return null;
}
// 已是有效 JSON对象或数组则直接使用
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
try {
JSON.parse(trimmed);
return trimmed;
} catch (Exception ignored) {
// 解析失败当作普通文本处理
}
}
// 普通文本包装为 JSON 字符串
return JSON.toJSONString(assessmentData);
}
/**
* 查询教师的课程环节带权限检查
*/