学校课程管理: 前后端对齐,实现搜索与年级筛选功能

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-18 10:14:53 +08:00
parent 6b5d0e171b
commit 7fe2c319ee
5 changed files with 107 additions and 51 deletions

View File

@ -380,8 +380,13 @@ export interface Course {
publishedAt?: string; publishedAt?: string;
} }
export const getSchoolCourses = () => export interface SchoolCourseQueryParams {
http.get<Course[]>('/v1/school/courses'); keyword?: string;
grade?: string; // 小班|中班|大班 或 small|middle|big
}
export const getSchoolCourses = (params?: SchoolCourseQueryParams) =>
http.get<Course[]>('/v1/school/courses', { params });
export const getSchoolCourse = (id: number) => export const getSchoolCourse = (id: number) =>
http.get<Course>(`/v1/school/courses/${id}`); http.get<Course>(`/v1/school/courses/${id}`);

View File

@ -31,7 +31,7 @@
<span class="tab-label">年级筛选</span> <span class="tab-label">年级筛选</span>
<div class="tab-buttons"> <div class="tab-buttons">
<div v-for="grade in gradeOptions" :key="grade.value" class="grade-tab" <div v-for="grade in gradeOptions" :key="grade.value" class="grade-tab"
:class="{ active: selectedGrade === grade.value }" @click="selectedGrade = grade.value"> :class="{ active: selectedGrade === grade.value }" @click="handleGradeChange(grade.value)">
{{ grade.label }} {{ grade.label }}
</div> </div>
</div> </div>
@ -49,8 +49,8 @@
</div> </div>
<!-- 课程卡片网格 --> <!-- 课程卡片网格 -->
<div class="course-grid" v-if="!loading && filteredCourses.length > 0"> <div class="course-grid" v-if="!loading && courses.length > 0">
<div v-for="course in filteredCourses" :key="course.id" class="course-card" <div v-for="course in courses" :key="course.id" class="course-card"
:class="{ 'unauthorized': !course.authorized }"> :class="{ 'unauthorized': !course.authorized }">
<div class="card-cover"> <div class="card-cover">
<div class="cover-placeholder" v-if="!course.pictureUrl"> <div class="cover-placeholder" v-if="!course.pictureUrl">
@ -69,11 +69,11 @@
<p class="course-book">{{ course.pictureBookName }}</p> <p class="course-book">{{ course.pictureBookName }}</p>
<div class="course-tags"> <div class="course-tags">
<span v-for="tag in course.gradeTags.slice(0, 2)" :key="tag" class="tag grade" <span v-for="tag in (course.gradeTags || []).slice(0, 2)" :key="tag" class="tag grade"
:style="getGradeTagStyle(translateGradeTag(tag))"> :style="getGradeTagStyle(translateGradeTag(tag))">
{{ translateGradeTag(tag) }} {{ translateGradeTag(tag) }}
</span> </span>
<span v-for="tag in course.domainTags.slice(0, 2)" :key="tag" class="tag domain" <span v-for="tag in (course.domainTags || []).slice(0, 2)" :key="tag" class="tag domain"
:style="getDomainTagStyle(translateDomainTag(tag))"> :style="getDomainTagStyle(translateDomainTag(tag))">
{{ translateDomainTag(tag) }} {{ translateDomainTag(tag) }}
</span> </span>
@ -112,11 +112,11 @@
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
<div class="empty-state" v-if="!loading && filteredCourses.length === 0"> <div class="empty-state" v-if="!loading && courses.length === 0">
<div class="empty-icon-wrapper"> <div class="empty-icon-wrapper">
<BookOutlined class="empty-icon" /> <BookOutlined class="empty-icon" />
</div> </div>
<p>{{ searchKeyword ? '未找到匹配的课程' : '暂无课程数据' }}</p> <p>{{ (searchKeyword || selectedGrade) ? '未找到匹配的课程' : '暂无课程数据' }}</p>
<a-button type="primary" @click="showAuthModal"> <a-button type="primary" @click="showAuthModal">
授权第一门课程 授权第一门课程
</a-button> </a-button>
@ -214,6 +214,21 @@ const searchKeyword = ref('');
const selectedCourseIds = ref<number[]>([]); const selectedCourseIds = ref<number[]>([]);
const selectedGrade = ref(''); // const selectedGrade = ref(''); //
// JSON
const parseTags = (val: any): string[] => {
if (!val) return [];
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try {
const parsed = JSON.parse(val);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
};
// //
const gradeOptions = [ const gradeOptions = [
{ label: '全部', value: '' }, { label: '全部', value: '' },
@ -223,35 +238,17 @@ const gradeOptions = [
]; ];
const authorizedCount = computed(() => courses.value.filter(c => c.authorized).length); const authorizedCount = computed(() => courses.value.filter(c => c.authorized).length);
const totalUsage = computed(() => courses.value.reduce((sum, c) => sum + c.usageCount, 0)); const totalUsage = computed(() => courses.value.reduce((sum, c) => sum + (c.usageCount || 0), 0));
// //
const filteredCourses = computed(() => { const handleGradeChange = (value: string) => {
let result = courses.value; selectedGrade.value = value;
loadCourses();
};
// //
if (selectedGrade.value) { const handleSearch = () => {
result = result.filter(c => { loadCourses();
const gradeTags = c.gradeTags || [];
return gradeTags.some((tag: string) => translateGradeTag(tag) === selectedGrade.value);
});
}
//
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase();
result = result.filter(c =>
c.name.toLowerCase().includes(keyword) ||
c.pictureBookName.toLowerCase().includes(keyword)
);
}
return result;
});
//
const handleGradeChange = () => {
//
}; };
const columns = [ const columns = [
@ -283,15 +280,21 @@ const pagination = reactive({
const courses = ref<any[]>([]); const courses = ref<any[]>([]);
const availableCourses = ref<any[]>([]); const availableCourses = ref<any[]>([]);
// //
const loadCourses = async () => { const loadCourses = async () => {
loading.value = true; loading.value = true;
try { try {
const data = await schoolApi.getSchoolCourses(); const params: { keyword?: string; grade?: string } = {};
courses.value = data.map((course: any) => ({ if (searchKeyword.value?.trim()) params.keyword = searchKeyword.value.trim();
if (selectedGrade.value) params.grade = selectedGrade.value;
const data = await schoolApi.getSchoolCourses(params);
courses.value = (data || []).map((course: any) => ({
...course, ...course,
gradeTags: course.gradeTags || [], gradeTags: parseTags(course.gradeTags),
domainTags: course.domainTags || [], domainTags: parseTags(course.domainTags),
duration: course.duration ?? course.durationMinutes ?? 0,
pictureUrl: course.pictureUrl ?? course.coverImagePath ?? course.coverUrl,
authorized: course.authorized ?? true,
})); }));
pagination.total = courses.value.length; pagination.total = courses.value.length;
} catch (error: any) { } catch (error: any) {
@ -301,10 +304,6 @@ const loadCourses = async () => {
} }
}; };
const handleSearch = () => {
// filteredCourses computed
};
const handleTableChange = (pag: any) => { const handleTableChange = (pag: any) => {
pagination.current = pag.current; pagination.current = pag.current;
pagination.pageSize = pag.pageSize; pagination.pageSize = pag.pageSize;

View File

@ -27,10 +27,12 @@ public class SchoolCourseController {
@GetMapping @GetMapping
@Operation(summary = "获取学校课程包列表") @Operation(summary = "获取学校课程包列表")
public Result<List<Course>> getSchoolCourses() { public Result<List<Course>> getSchoolCourses(
log.info("获取学校课程包列表"); @RequestParam(required = false) String keyword,
@RequestParam(required = false) String grade) {
log.info("获取学校课程包列表keyword={}, grade={}", keyword, grade);
Long tenantId = SecurityUtils.getCurrentTenantId(); Long tenantId = SecurityUtils.getCurrentTenantId();
List<Course> courses = courseService.getTenantPackageCourses(tenantId); List<Course> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade);
return Result.success(courses); return Result.success(courses);
} }

View File

@ -53,7 +53,11 @@ public interface CourseService extends com.baomidou.mybatisplus.extension.servic
/** /**
* 查询租户套餐下的课程 * 查询租户套餐下的课程
*
* @param tenantId 租户 ID
* @param keyword 关键词课程名称绘本名称可选
* @param grade 年级筛选小班/中班/大班 small/middle/big可选
*/ */
List<Course> getTenantPackageCourses(Long tenantId); List<Course> getTenantPackageCourses(Long tenantId, String keyword, String grade);
} }

View File

@ -15,6 +15,7 @@ import com.reading.platform.dto.response.CourseResponse;
import com.reading.platform.dto.response.LessonStepResponse; import com.reading.platform.dto.response.LessonStepResponse;
import com.reading.platform.entity.Course; import com.reading.platform.entity.Course;
import com.reading.platform.entity.CourseLesson; import com.reading.platform.entity.CourseLesson;
import com.alibaba.fastjson2.JSON;
import com.reading.platform.entity.CoursePackage; import com.reading.platform.entity.CoursePackage;
import com.reading.platform.entity.CoursePackageCourse; import com.reading.platform.entity.CoursePackageCourse;
import com.reading.platform.entity.LessonStep; import com.reading.platform.entity.LessonStep;
@ -479,8 +480,8 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
} }
@Override @Override
public List<Course> getTenantPackageCourses(Long tenantId) { public List<Course> getTenantPackageCourses(Long tenantId, String keyword, String grade) {
log.info("查询租户套餐下的课程tenantId={}", tenantId); log.info("查询租户套餐下的课程tenantId={}, keyword={}, grade={}", tenantId, keyword, grade);
// 1. 查询租户的套餐关联 // 1. 查询租户的套餐关联
List<TenantPackage> tenantPackages = tenantPackageMapper.selectList( List<TenantPackage> tenantPackages = tenantPackageMapper.selectList(
@ -538,10 +539,55 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
.ne(Course::getStatus, CourseStatus.ARCHIVED.getCode()) .ne(Course::getStatus, CourseStatus.ARCHIVED.getCode())
); );
// 7. 按关键词和年级筛选
String kw = StringUtils.hasText(keyword) ? keyword.trim().toLowerCase() : null;
List<String> gradeKeys = parseGradeFilter(grade);
if (kw != null || !gradeKeys.isEmpty()) {
courses = courses.stream()
.filter(c -> matchKeyword(c, kw))
.filter(c -> matchGrade(c, gradeKeys))
.collect(Collectors.toList());
}
log.info("查询租户套餐下的课程包成功tenantId={}, count={}", tenantId, courses.size()); log.info("查询租户套餐下的课程包成功tenantId={}, count={}", tenantId, courses.size());
return courses; return courses;
} }
/** 解析年级筛选:小班/中班/大班 -> small,middle,big含大小写 */
private List<String> parseGradeFilter(String grade) {
if (!StringUtils.hasText(grade)) return List.of();
String g = grade.trim();
return switch (g) {
case "小班" -> List.of("small", "SMALL");
case "中班" -> List.of("middle", "MIDDLE");
case "大班" -> List.of("big", "BIG");
case "small", "SMALL" -> List.of("small", "SMALL");
case "middle", "MIDDLE" -> List.of("middle", "MIDDLE");
case "big", "BIG" -> List.of("big", "BIG");
default -> List.of();
};
}
private boolean matchKeyword(Course c, String keyword) {
if (keyword == null) return true;
String name = c.getName() != null ? c.getName().toLowerCase() : "";
String book = c.getPictureBookName() != null ? c.getPictureBookName().toLowerCase() : "";
return name.contains(keyword) || book.contains(keyword);
}
private boolean matchGrade(Course c, List<String> gradeKeys) {
if (gradeKeys.isEmpty()) return true;
String tags = c.getGradeTags();
if (!StringUtils.hasText(tags)) return false;
try {
List<String> list = JSON.parseArray(tags, String.class);
if (list == null) return false;
return list.stream().anyMatch(gradeKeys::contains);
} catch (Exception e) {
return false;
}
}
/** /**
* 将空字符串转为 null避免 MySQL JSON 列报错空串不是有效 JSON * 将空字符串转为 null避免 MySQL JSON 列报错空串不是有效 JSON
*/ */