feat: 校园端课程列表筛选与卡片展示参考教师端
- 筛选栏:年级/领域/课程类型下拉、搜索框,保留授权新课程按钮 - 卡片:新增 lessonTags 展示(导入课、集体课、领域等) - 后端:学校课程 API 支持 domain、lessonType 参数及 lessonTags 返回 - 主题保持校园端绿色 Made-with: Cursor
This commit is contained in:
parent
20c500e921
commit
f425209abe
@ -396,7 +396,9 @@ export interface Course {
|
||||
|
||||
export interface SchoolCourseQueryParams {
|
||||
keyword?: string;
|
||||
grade?: string; // 小班|中班|大班 或 small|middle|big
|
||||
grade?: string; // 小班|中班|大班
|
||||
domain?: string; // 健康|语言|社会|科学|艺术(传英文码)
|
||||
lessonType?: string; // INTRODUCTION|COLLECTIVE|LANGUAGE|HEALTH|SCIENCE|SOCIAL|ART
|
||||
}
|
||||
|
||||
export const getSchoolCourses = (params?: SchoolCourseQueryParams) =>
|
||||
|
||||
@ -25,26 +25,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 年级切换Tab + 操作栏 -->
|
||||
<!-- 筛选栏(参考教师端) -->
|
||||
<div class="filter-action-bar">
|
||||
<div class="grade-tabs">
|
||||
<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="handleGradeChange(grade.value)">
|
||||
{{ grade.label }}
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">年级</span>
|
||||
<a-select v-model:value="filters.grade" placeholder="全部年级" style="width: 120px;" allowClear
|
||||
@change="handleFilterChange">
|
||||
<a-select-option value="小班">小班</a-select-option>
|
||||
<a-select-option value="中班">中班</a-select-option>
|
||||
<a-select-option value="大班">大班</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<div class="search-box">
|
||||
<a-input-search v-model:value="searchKeyword" placeholder="搜索课程名称" style="width: 280px;"
|
||||
@search="handleSearch" allow-clear />
|
||||
<!-- <div class="filter-item">
|
||||
<span class="filter-label">领域</span>
|
||||
<a-select v-model:value="filters.domain" placeholder="全部领域" style="width: 120px;" allowClear
|
||||
@change="handleFilterChange">
|
||||
<a-select-option value="健康">健康</a-select-option>
|
||||
<a-select-option value="语言">语言</a-select-option>
|
||||
<a-select-option value="社会">社会</a-select-option>
|
||||
<a-select-option value="科学">科学</a-select-option>
|
||||
<a-select-option value="艺术">艺术</a-select-option>
|
||||
</a-select>
|
||||
</div> -->
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">课程类型</span>
|
||||
<a-select v-model:value="filters.lessonType" placeholder="全部类型" style="width: 130px;" allowClear
|
||||
@change="handleFilterChange">
|
||||
<a-select-option value="INTRODUCTION">导入课</a-select-option>
|
||||
<a-select-option value="COLLECTIVE">集体课</a-select-option>
|
||||
<a-select-option value="LANGUAGE">语言</a-select-option>
|
||||
<a-select-option value="HEALTH">健康</a-select-option>
|
||||
<a-select-option value="SCIENCE">科学</a-select-option>
|
||||
<a-select-option value="SOCIAL">社会</a-select-option>
|
||||
<a-select-option value="ART">艺术</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="filter-item search-box">
|
||||
<a-input-search v-model:value="filters.keyword" placeholder="搜索课程名称..." style="width: 240px;"
|
||||
@search="handleFilterChange" allow-clear />
|
||||
</div>
|
||||
<div class="filter-item filter-right">
|
||||
<a-button type="primary" class="auth-btn" @click="showAuthModal">
|
||||
<StarFilled class="btn-icon" />
|
||||
授权新课程
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button type="primary" class="auth-btn" @click="showAuthModal">
|
||||
<StarFilled class="btn-icon" />
|
||||
授权新课程
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -69,14 +95,12 @@
|
||||
<p class="course-book">《{{ course.pictureBookName }}》</p>
|
||||
|
||||
<div class="course-tags">
|
||||
<span v-for="tag in (course.gradeTags || [])" :key="tag" class="tag grade"
|
||||
:style="getGradeTagStyle(translateGradeTag(tag))">
|
||||
{{ translateGradeTag(tag) }}
|
||||
</span>
|
||||
<span v-for="tag in (course.domainTags || [])" :key="tag" class="tag domain"
|
||||
:style="getDomainTagStyle(translateDomainTag(tag))">
|
||||
{{ translateDomainTag(tag) }}
|
||||
</span>
|
||||
<span v-for="tag in (course.gradeTags || [])" :key="'g-' + tag" class="tag grade"
|
||||
:style="getGradeTagStyle(tag)">{{ tag }}</span>
|
||||
<span v-for="tag in (course.domainTags || [])" :key="'d-' + tag" class="tag domain"
|
||||
:style="getDomainTagStyle(tag)">{{ tag }}</span>
|
||||
<span v-for="(lt, idx) in (course.lessonTags || [])" :key="'l-' + idx" class="tag lesson"
|
||||
:style="getLessonTagStyle(lt.lessonType)">{{ getLessonTypeName(lt.lessonType) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="course-meta">
|
||||
@ -116,7 +140,7 @@
|
||||
<div class="empty-icon-wrapper">
|
||||
<BookOutlined class="empty-icon" />
|
||||
</div>
|
||||
<p>{{ (searchKeyword || selectedGrade) ? '未找到匹配的课程' : '暂无课程数据' }}</p>
|
||||
<p>{{ hasFilters ? '暂无符合条件的课程,试试调整筛选条件吧' : '暂无课程数据' }}</p>
|
||||
<a-button type="primary" @click="showAuthModal">
|
||||
授权第一门课程
|
||||
</a-button>
|
||||
@ -200,8 +224,8 @@ import {
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
translateGradeTag,
|
||||
translateDomainTag,
|
||||
translateGradeTags,
|
||||
translateDomainTags,
|
||||
getGradeTagStyle,
|
||||
getDomainTagStyle,
|
||||
} from '@/utils/tagMaps';
|
||||
@ -214,27 +238,55 @@ const authLoading = ref(false);
|
||||
const authModalVisible = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const selectedCourseIds = ref<number[]>([]);
|
||||
const selectedGrade = ref(''); // 选中的年级
|
||||
|
||||
// 年级选项
|
||||
const gradeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '小班', value: '小班' },
|
||||
{ label: '中班', value: '中班' },
|
||||
{ label: '大班', value: '大班' },
|
||||
];
|
||||
// 筛选条件(参考教师端)
|
||||
const filters = reactive({
|
||||
grade: undefined as string | undefined,
|
||||
domain: undefined as string | undefined,
|
||||
lessonType: undefined as string | undefined,
|
||||
keyword: '',
|
||||
});
|
||||
|
||||
const authorizedCount = computed(() => courses.value.filter(c => c.authorized).length);
|
||||
const totalUsage = computed(() => courses.value.reduce((sum, c) => sum + (c.usageCount || 0), 0));
|
||||
const hasFilters = computed(() =>
|
||||
!!(filters.grade || filters.domain || filters.lessonType || filters.keyword?.trim())
|
||||
);
|
||||
|
||||
// 年级切换:请求后端筛选
|
||||
const handleGradeChange = (value: string) => {
|
||||
selectedGrade.value = value;
|
||||
loadCourses();
|
||||
// 五大领域:中文 -> 后端英文码
|
||||
const DOMAIN_TO_CODE: Record<string, string> = {
|
||||
健康: 'HEALTH',
|
||||
语言: 'LANGUAGE',
|
||||
社会: 'SOCIAL',
|
||||
科学: 'SCIENCE',
|
||||
艺术: 'ART',
|
||||
};
|
||||
|
||||
// 搜索:请求后端筛选
|
||||
const handleSearch = () => {
|
||||
// 课程环节类型映射
|
||||
const LESSON_TYPE_NAMES: Record<string, string> = {
|
||||
INTRODUCTION: '导入课', INTRO: '导入课', COLLECTIVE: '集体课',
|
||||
LANGUAGE: '语言', HEALTH: '健康', SCIENCE: '科学', SOCIAL: '社会', ART: '艺术',
|
||||
DOMAIN_HEALTH: '健康', DOMAIN_LANGUAGE: '语言', DOMAIN_SOCIAL: '社会',
|
||||
DOMAIN_SCIENCE: '科学', DOMAIN_ART: '艺术',
|
||||
};
|
||||
const getLessonTypeName = (type: string) => LESSON_TYPE_NAMES[type] || type;
|
||||
|
||||
const getLessonTagStyle = (type: string) => {
|
||||
const colors: Record<string, { background: string; color: string }> = {
|
||||
INTRODUCTION: { background: '#E8F5E9', color: '#2E7D32' }, INTRO: { background: '#E8F5E9', color: '#2E7D32' },
|
||||
COLLECTIVE: { background: '#E3F2FD', color: '#1565C0' },
|
||||
LANGUAGE: { background: '#F3E5F5', color: '#7B1FA2' }, HEALTH: { background: '#FFEBEE', color: '#C62828' },
|
||||
SCIENCE: { background: '#E8F5E9', color: '#388E3C' }, SOCIAL: { background: '#E0F7FA', color: '#00838F' },
|
||||
ART: { background: '#FFF3E0', color: '#E65100' },
|
||||
DOMAIN_HEALTH: { background: '#FFEBEE', color: '#C62828' }, DOMAIN_LANGUAGE: { background: '#F3E5F5', color: '#7B1FA2' },
|
||||
DOMAIN_SOCIAL: { background: '#E0F7FA', color: '#00838F' }, DOMAIN_SCIENCE: { background: '#E8F5E9', color: '#388E3C' },
|
||||
DOMAIN_ART: { background: '#FFF3E0', color: '#E65100' },
|
||||
};
|
||||
const c = colors[type] || { background: '#F5F5F5', color: '#666' };
|
||||
return { background: c.background, color: c.color, border: 'none' };
|
||||
};
|
||||
|
||||
const handleFilterChange = () => {
|
||||
loadCourses();
|
||||
};
|
||||
|
||||
@ -267,20 +319,54 @@ const pagination = reactive({
|
||||
const courses = ref<any[]>([]);
|
||||
const availableCourses = ref<any[]>([]);
|
||||
|
||||
// 加载课程列表(支持后端搜索与年级筛选)
|
||||
// 解析标签
|
||||
const parseTags = (val: any): string[] => {
|
||||
if (!val) return [];
|
||||
if (Array.isArray(val)) {
|
||||
if (val.length === 0) return [];
|
||||
if (val[0]?.toString().startsWith('[')) {
|
||||
try {
|
||||
return JSON.parse(val.join(''));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return val;
|
||||
}
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// 加载课程列表(支持年级、领域、课程类型、关键词筛选)
|
||||
const loadCourses = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: { keyword?: string; grade?: string } = {};
|
||||
if (searchKeyword.value?.trim()) params.keyword = searchKeyword.value.trim();
|
||||
if (selectedGrade.value) params.grade = selectedGrade.value;
|
||||
const params: schoolApi.SchoolCourseQueryParams = {};
|
||||
if (filters.keyword?.trim()) params.keyword = filters.keyword.trim();
|
||||
if (filters.grade) params.grade = filters.grade;
|
||||
if (filters.domain) params.domain = DOMAIN_TO_CODE[filters.domain] ?? filters.domain;
|
||||
if (filters.lessonType) params.lessonType = filters.lessonType;
|
||||
const data = await schoolApi.getSchoolCourses(params);
|
||||
courses.value = (data || []).map((course: any) => ({
|
||||
...course,
|
||||
duration: course.duration ?? course.durationMinutes ?? 0,
|
||||
pictureUrl: course.pictureUrl ?? course.coverImagePath ?? course.coverUrl,
|
||||
authorized: course.authorized ?? true,
|
||||
}));
|
||||
courses.value = (data || []).map((course: any) => {
|
||||
const gradeTags = parseTags(course.gradeTags);
|
||||
const domainTags = parseTags(course.domainTags);
|
||||
return {
|
||||
...course,
|
||||
gradeTags: translateGradeTags(gradeTags),
|
||||
domainTags: translateDomainTags(domainTags),
|
||||
lessonTags: course.lessonTags || [],
|
||||
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) {
|
||||
message.error(error.response?.data?.message || '加载课程列表失败');
|
||||
@ -447,11 +533,8 @@ onMounted(() => {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* 筛选操作栏 */
|
||||
/* 筛选操作栏(参考教师端布局) */
|
||||
.filter-action-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px 24px;
|
||||
background: white;
|
||||
@ -459,51 +542,33 @@ onMounted(() => {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.grade-tabs {
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
.filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grade-tab {
|
||||
padding: 8px 20px;
|
||||
border-radius: 10px;
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
background: #F5F5F5;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.grade-tab:hover {
|
||||
background: #E8F5E9;
|
||||
color: #43e97b;
|
||||
.filter-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.grade-tab.active {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(67, 233, 123, 0.3);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* 操作栏 */
|
||||
|
||||
@ -3,8 +3,11 @@ package com.reading.platform.controller.school;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.common.security.SecurityUtils;
|
||||
import com.reading.platform.dto.response.LessonTagResponse;
|
||||
import com.reading.platform.dto.response.SchoolCourseResponse;
|
||||
import com.reading.platform.entity.CourseLesson;
|
||||
import com.reading.platform.entity.CoursePackage;
|
||||
import com.reading.platform.service.CourseLessonService;
|
||||
import com.reading.platform.service.CoursePackageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -28,18 +31,31 @@ import java.util.stream.Collectors;
|
||||
public class SchoolCourseController {
|
||||
|
||||
private final CoursePackageService courseService;
|
||||
private final CourseLessonService courseLessonService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "获取学校课程包列表")
|
||||
public Result<List<SchoolCourseResponse>> getSchoolCourses(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String grade) {
|
||||
log.info("获取学校课程包列表,keyword={}, grade={}", keyword, grade);
|
||||
@RequestParam(required = false) String grade,
|
||||
@RequestParam(required = false) String domain,
|
||||
@RequestParam(required = false) String lessonType) {
|
||||
log.info("获取学校课程包列表,keyword={}, grade={}, domain={}, lessonType={}", keyword, grade, domain, lessonType);
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
List<CoursePackage> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade, null);
|
||||
List<CoursePackage> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade, domain, lessonType);
|
||||
List<SchoolCourseResponse> list = courses.stream()
|
||||
.map(this::toSchoolCourseResponse)
|
||||
.map(pkg -> toSchoolCourseResponse(pkg))
|
||||
.collect(Collectors.toList());
|
||||
// 填充 lessonTags
|
||||
for (SchoolCourseResponse vo : list) {
|
||||
List<CourseLesson> lessons = courseLessonService.findByCourseId(vo.getId());
|
||||
vo.setLessonTags(lessons.stream()
|
||||
.map(l -> LessonTagResponse.builder()
|
||||
.name(l.getName())
|
||||
.lessonType(l.getLessonType())
|
||||
.build())
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@ public class TeacherCourseController {
|
||||
@RequestParam(required = false) String domain) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
// 按 学校 -> 套餐 -> 课程包 层级查询
|
||||
List<CoursePackage> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade, domain);
|
||||
List<CoursePackage> courses = courseService.getTenantPackageCourses(tenantId, keyword, grade, domain, null);
|
||||
return Result.success(courseMapper.toVO(courses));
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学校端课程响应(gradeTags/domainTags 规范为 String[],与套餐管理适用年级对齐)
|
||||
@ -65,4 +66,7 @@ public class SchoolCourseResponse {
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Schema(description = "课程环节标签(列表展示用,仅 name 和 lessonType)")
|
||||
private List<LessonTagResponse> lessonTags;
|
||||
}
|
||||
|
||||
@ -59,12 +59,13 @@ public interface CoursePackageService extends com.baomidou.mybatisplus.extension
|
||||
/**
|
||||
* 查询租户套餐下的课程
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param keyword 关键词(课程名称、绘本名称,可选)
|
||||
* @param grade 年级筛选(小班/中班/大班,可选)
|
||||
* @param domain 领域筛选(健康/语言/社会/科学/艺术 或对应英文码,可选)
|
||||
* @param tenantId 租户 ID
|
||||
* @param keyword 关键词(课程名称、绘本名称,可选)
|
||||
* @param grade 年级筛选(小班/中班/大班,可选)
|
||||
* @param domain 领域筛选(健康/语言/社会/科学/艺术 或对应英文码,可选)
|
||||
* @param lessonType 课程环节类型筛选(可选)
|
||||
*/
|
||||
List<CoursePackage> getTenantPackageCourses(Long tenantId, String keyword, String grade, String domain);
|
||||
List<CoursePackage> getTenantPackageCourses(Long tenantId, String keyword, String grade, String domain, String lessonType);
|
||||
|
||||
/**
|
||||
* 按 学校 -> 套餐 -> 课程包 层级分页查询教师可用课程
|
||||
|
||||
@ -227,7 +227,7 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CoursePackage> getTenantPackageCourses(Long tenantId, String keyword, String grade, String domain) {
|
||||
public List<CoursePackage> getTenantPackageCourses(Long tenantId, String keyword, String grade, String domain, String lessonType) {
|
||||
List<Long> collectionIds = tenantPackageMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, tenantId)
|
||||
@ -254,6 +254,16 @@ public class CoursePackageServiceImpl extends ServiceImpl<CoursePackageMapper, C
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// lessonType 筛选:仅保留包含该类型环节的课程包
|
||||
if (StringUtils.hasText(lessonType)) {
|
||||
Set<Long> courseIdsWithLesson = courseLessonService.findCourseIdsByLessonType(lessonType).stream()
|
||||
.collect(Collectors.toSet());
|
||||
packageIds = packageIds.stream().filter(courseIdsWithLesson::contains).collect(Collectors.toList());
|
||||
if (packageIds.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<CoursePackage> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.in(CoursePackage::getId, packageIds)
|
||||
.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user