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

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

View File

@ -31,7 +31,7 @@
<span class="tab-label">年级筛选</span>
<div class="tab-buttons">
<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 }}
</div>
</div>
@ -49,8 +49,8 @@
</div>
<!-- 课程卡片网格 -->
<div class="course-grid" v-if="!loading && filteredCourses.length > 0">
<div v-for="course in filteredCourses" :key="course.id" class="course-card"
<div class="course-grid" v-if="!loading && courses.length > 0">
<div v-for="course in courses" :key="course.id" class="course-card"
:class="{ 'unauthorized': !course.authorized }">
<div class="card-cover">
<div class="cover-placeholder" v-if="!course.pictureUrl">
@ -69,11 +69,11 @@
<p class="course-book">{{ course.pictureBookName }}</p>
<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))">
{{ translateGradeTag(tag) }}
</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))">
{{ translateDomainTag(tag) }}
</span>
@ -112,11 +112,11 @@
</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">
<BookOutlined class="empty-icon" />
</div>
<p>{{ searchKeyword ? '未找到匹配的课程' : '暂无课程数据' }}</p>
<p>{{ (searchKeyword || selectedGrade) ? '未找到匹配的课程' : '暂无课程数据' }}</p>
<a-button type="primary" @click="showAuthModal">
授权第一门课程
</a-button>
@ -214,6 +214,21 @@ const searchKeyword = ref('');
const selectedCourseIds = ref<number[]>([]);
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 = [
{ label: '全部', value: '' },
@ -223,35 +238,17 @@ const gradeOptions = [
];
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(() => {
let result = courses.value;
//
const handleGradeChange = (value: string) => {
selectedGrade.value = value;
loadCourses();
};
//
if (selectedGrade.value) {
result = result.filter(c => {
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 handleSearch = () => {
loadCourses();
};
const columns = [
@ -283,15 +280,21 @@ const pagination = reactive({
const courses = ref<any[]>([]);
const availableCourses = ref<any[]>([]);
//
//
const loadCourses = async () => {
loading.value = true;
try {
const data = await schoolApi.getSchoolCourses();
courses.value = data.map((course: any) => ({
const params: { keyword?: string; grade?: string } = {};
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,
gradeTags: course.gradeTags || [],
domainTags: course.domainTags || [],
gradeTags: parseTags(course.gradeTags),
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;
} catch (error: any) {
@ -301,10 +304,6 @@ const loadCourses = async () => {
}
};
const handleSearch = () => {
// filteredCourses computed
};
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;

View File

@ -27,10 +27,12 @@ public class SchoolCourseController {
@GetMapping
@Operation(summary = "获取学校课程包列表")
public Result<List<Course>> getSchoolCourses() {
log.info("获取学校课程包列表");
public Result<List<Course>> getSchoolCourses(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String grade) {
log.info("获取学校课程包列表keyword={}, grade={}", keyword, grade);
Long tenantId = SecurityUtils.getCurrentTenantId();
List<Course> courses = courseService.getTenantPackageCourses(tenantId);
List<Course> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade);
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.entity.Course;
import com.reading.platform.entity.CourseLesson;
import com.alibaba.fastjson2.JSON;
import com.reading.platform.entity.CoursePackage;
import com.reading.platform.entity.CoursePackageCourse;
import com.reading.platform.entity.LessonStep;
@ -479,8 +480,8 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
}
@Override
public List<Course> getTenantPackageCourses(Long tenantId) {
log.info("查询租户套餐下的课程tenantId={}", tenantId);
public List<Course> getTenantPackageCourses(Long tenantId, String keyword, String grade) {
log.info("查询租户套餐下的课程tenantId={}, keyword={}, grade={}", tenantId, keyword, grade);
// 1. 查询租户的套餐关联
List<TenantPackage> tenantPackages = tenantPackageMapper.selectList(
@ -538,10 +539,55 @@ public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course>
.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());
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
*/