feat: 学校端课程中心优化 - 照搬教师端实现

主要变更:
- 学校端课程详情页返回完整 CourseResponse(与教师端一致)
- 新增课程中心视图 CourseCenterView.vue(学校端/教师端)
- 新增 course-center.ts API 层
- 新增 PackageFilterMetaResponse 用于筛选元数据
- 菜单文案修改:课程管理 -> 课程中心

后端优化:
- SchoolCourseController.getCourse() 返回 CourseResponse
- CourseCollectionService 新增筛选元数据查询方法
- CoursePackageResponse 新增 filterMeta 字段

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.6 2026-03-21 18:14:49 +08:00
parent cf7e6bf94f
commit 3183d1d388
14 changed files with 2119 additions and 13 deletions

View File

@ -0,0 +1,83 @@
import { http } from './index';
// ============= 类型定义 =============
/** 套餐信息 */
export interface CourseCollection {
id: number;
name: string;
description?: string;
packageCount: number;
gradeLevels?: string[];
status: string;
startDate?: string;
endDate?: string;
}
/** 课程包信息 */
export interface CoursePackage {
id: number;
name: string;
description?: string;
coverImagePath?: string;
pictureBookName?: string;
gradeTags: string[];
domainTags?: string[];
themeId?: number;
themeName?: string;
durationMinutes?: number;
usageCount?: number;
avgRating?: number;
sortOrder?: number;
}
/** 筛选元数据 - 年级选项 */
export interface GradeOption {
label: string;
count: number;
}
/** 筛选元数据 - 主题选项 */
export interface ThemeOption {
id: number;
name: string;
count: number;
}
/** 筛选元数据响应 */
export interface FilterMetaResponse {
grades: GradeOption[];
themes: ThemeOption[];
}
// ============= API 接口 =============
/**
*
*/
export function getCollections(): Promise<CourseCollection[]> {
return http.get<CourseCollection[]>('/v1/school/packages');
}
/**
*
*/
export function getPackages(
collectionId: number,
params?: {
grade?: string;
themeId?: number;
keyword?: string;
}
): Promise<CoursePackage[]> {
return http.get<CoursePackage[]>(`/v1/school/packages/${collectionId}/packages`, {
params,
});
}
/**
*
*/
export function getFilterMeta(collectionId: number): Promise<FilterMetaResponse> {
return http.get<FilterMetaResponse>(`/v1/school/packages/${collectionId}/filter-meta`);
}

View File

@ -338,6 +338,24 @@ export const getCourseCollections = () =>
export const getCourseCollectionPackages = (collectionId: number | string) =>
http.get<CoursePackageItem[]>(`/v1/school/packages/${collectionId}/packages`);
// 获取课程包详情(包含课程环节列表)
export interface CoursePackageDetail {
id: number;
name: string;
description?: string;
courses: Array<{
id: number;
name: string;
lessonType?: string;
gradeLevel?: string;
sortOrder?: number;
scheduleRefData?: string;
}>;
}
export const getCoursePackageDetail = (packageId: number | string) =>
http.get<CoursePackageDetail>(`/v1/school/packages/packages/${packageId}/courses`);
// 续费课程套餐(三层架构)
export const renewCollection = (collectionId: number, data: RenewPackageDto) =>
http.post<void>(`/v1/school/packages/${collectionId}/renew`, data);
@ -423,8 +441,8 @@ export const getSchoolCourseList = (params?: {
}) =>
http.get<{ list: Course[]; total: number; pageNum: number; pageSize: number; pages: number }>('/v1/school/courses', { params });
export const getSchoolCourse = (id: number) =>
http.get<Course>(`/v1/school/courses/${id}`);
export const getSchoolCourse = (id: number | string): Promise<any> =>
http.get(`/v1/school/courses/${id}`) as any;
// ==================== 班级教师管理 ====================

View File

@ -162,8 +162,8 @@ const routes: RouteRecordRaw[] = [
{
path: 'courses',
name: 'SchoolCourses',
component: () => import('@/views/school/courses/CourseListView.vue'),
meta: { title: '课程管理' },
component: () => import('@/views/school/courses-new/CourseCenterView.vue'),
meta: { title: '课程中心' },
},
{
path: 'courses/:id',
@ -276,7 +276,7 @@ const routes: RouteRecordRaw[] = [
{
path: 'courses',
name: 'TeacherCourses',
component: () => import('@/views/teacher/courses/CourseListView.vue'),
component: () => import('@/views/teacher/courses-new/CourseCenterView.vue'),
meta: { title: '课程中心' },
},
{

View File

@ -59,7 +59,7 @@
<template #title>教学管理</template>
<a-menu-item key="courses">
<template #icon><ReadOutlined /></template>
<span>课程管理</span>
<span>课程中心</span>
</a-menu-item>
<a-menu-item key="school-courses">
<template #icon><FolderAddOutlined /></template>

View File

@ -0,0 +1,599 @@
<template>
<div class="course-center-page">
<!-- 左侧套餐列表 -->
<aside class="collection-sidebar">
<div class="sidebar-header">
<h3>课程套餐</h3>
</div>
<a-spin :spinning="loadingCollections">
<div class="collection-list">
<div
v-for="collection in collections"
:key="collection.id"
:class="['collection-item', { active: selectedCollectionId === collection.id }]"
@click="selectCollection(collection)"
>
<div class="collection-name">{{ collection.name }}</div>
<div class="collection-count">{{ collection.packageCount || 0 }}个课程包</div>
</div>
<div v-if="!loadingCollections && collections.length === 0" class="empty-collections">
<InboxOutlined />
<p>暂无可用套餐</p>
</div>
</div>
</a-spin>
</aside>
<!-- 右侧主内容区 -->
<main class="main-content">
<template v-if="selectedCollection">
<!-- 套餐信息区 -->
<section class="collection-info">
<h2 class="collection-title">{{ selectedCollection.name }}</h2>
<div v-if="selectedCollection.description" class="collection-description">
<div ref="descRef" :class="['desc-text', { expanded: descExpanded }]">
{{ selectedCollection.description }}
</div>
<button
v-if="showExpandBtn"
class="expand-btn"
@click="descExpanded = !descExpanded"
>
{{ descExpanded ? '收起' : '展开更多' }}
<DownOutlined :class="{ rotated: descExpanded }" />
</button>
</div>
</section>
<!-- 筛选区 -->
<section class="filter-section">
<div class="filter-row">
<!-- 年级筛选标签形式 -->
<div class="filter-group">
<span class="filter-label">年级</span>
<div class="grade-tags">
<span
:class="['grade-tag', { active: !selectedGrade }]"
@click="selectedGrade = ''"
>
全部
</span>
<span
v-for="grade in filterMeta.grades"
:key="grade.label"
:class="['grade-tag', { active: selectedGrade === grade.label }]"
@click="selectedGrade = grade.label"
>
{{ grade.label }}
<span class="count">({{ grade.count }})</span>
</span>
</div>
</div>
</div>
<div class="filter-row">
<!-- 主题筛选 -->
<div class="filter-group">
<span class="filter-label">主题</span>
<a-select
v-model:value="selectedThemeId"
placeholder="全部主题"
style="width: 180px"
allowClear
@change="loadPackages"
>
<a-select-option :value="undefined">全部主题</a-select-option>
<a-select-option
v-for="theme in filterMeta.themes"
:key="theme.id"
:value="theme.id"
>
{{ theme.name }} ({{ theme.count }})
</a-select-option>
</a-select>
</div>
<!-- 搜索 -->
<div class="filter-group search-group">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索课程包..."
style="width: 220px"
allowClear
@search="loadPackages"
/>
</div>
</div>
</section>
<!-- 课程包网格 -->
<section class="packages-section">
<a-spin :spinning="loadingPackages">
<div v-if="packages.length > 0" class="packages-grid">
<CoursePackageCard
v-for="pkg in packages"
:key="pkg.id"
:pkg="pkg"
@click="handlePackageClick"
@view="handlePackageView"
/>
</div>
<div v-else class="empty-packages">
<InboxOutlined class="empty-icon" />
<p class="empty-text">暂无符合条件的课程包</p>
<p class="empty-hint">试试调整筛选条件</p>
</div>
</a-spin>
</section>
</template>
<!-- 未选择套餐时的占位 -->
<div v-else class="no-selection">
<BookOutlined class="no-selection-icon" />
<p>请在左侧选择一个课程套餐</p>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
InboxOutlined,
DownOutlined,
BookOutlined,
} from '@ant-design/icons-vue';
import {
getCollections,
getPackages,
getFilterMeta,
type CourseCollection,
type CoursePackage,
type FilterMetaResponse,
} from '@/api/course-center';
import { getCoursePackageDetail } from '@/api/school';
import CoursePackageCard from './components/CoursePackageCard.vue';
const router = useRouter();
//
const collections = ref<CourseCollection[]>([]);
const loadingCollections = ref(false);
const selectedCollectionId = ref<number | null>(null);
const selectedCollection = computed(() =>
collections.value.find(c => c.id === selectedCollectionId.value)
);
//
const filterMeta = ref<FilterMetaResponse>({ grades: [], themes: [] });
//
const packages = ref<CoursePackage[]>([]);
const loadingPackages = ref(false);
//
const selectedGrade = ref('');
const selectedThemeId = ref<number | undefined>(undefined);
const searchKeyword = ref('');
//
const descRef = ref<HTMLElement | null>(null);
const descExpanded = ref(false);
const showExpandBtn = ref(false);
//
const loadCollections = async () => {
loadingCollections.value = true;
try {
const data = await getCollections();
collections.value = data || [];
//
if (collections.value.length > 0 && !selectedCollectionId.value) {
selectCollection(collections.value[0]);
}
} catch (error: any) {
message.error(error.message || '获取套餐列表失败');
} finally {
loadingCollections.value = false;
}
};
//
const selectCollection = async (collection: CourseCollection) => {
selectedCollectionId.value = collection.id;
//
selectedGrade.value = '';
selectedThemeId.value = undefined;
searchKeyword.value = '';
descExpanded.value = false;
//
await Promise.all([
loadFilterMeta(),
loadPackages(),
]);
//
nextTick(() => {
checkDescHeight();
});
};
//
const checkDescHeight = () => {
if (descRef.value) {
showExpandBtn.value = descRef.value.scrollHeight > descRef.value.clientHeight;
}
};
//
const loadFilterMeta = async () => {
if (!selectedCollectionId.value) return;
try {
filterMeta.value = await getFilterMeta(selectedCollectionId.value);
} catch (error) {
console.error('获取筛选元数据失败', error);
filterMeta.value = { grades: [], themes: [] };
}
};
//
const loadPackages = async () => {
if (!selectedCollectionId.value) return;
loadingPackages.value = true;
try {
packages.value = await getPackages(selectedCollectionId.value, {
grade: selectedGrade.value || undefined,
themeId: selectedThemeId.value,
keyword: searchKeyword.value || undefined,
});
} catch (error: any) {
message.error(error.message || '获取课程包列表失败');
packages.value = [];
} finally {
loadingPackages.value = false;
}
};
//
watch(selectedGrade, () => {
loadPackages();
});
//
const handlePackageClick = (pkg: CoursePackage) => {
//
router.push(`/school/courses/${pkg.id}`);
};
//
const handlePackageView = (pkg: CoursePackage) => {
router.push(`/school/courses/${pkg.id}`);
};
onMounted(() => {
loadCollections();
});
</script>
<style scoped>
.course-center-page {
display: flex;
min-height: calc(100vh - 120px);
background: #F5F7FA;
gap: 20px;
padding: 20px;
}
/* 左侧套餐列表 */
.collection-sidebar {
width: 220px;
flex-shrink: 0;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #F0F0F0;
}
.sidebar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.collection-list {
padding: 8px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.collection-item {
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border-left: 3px solid transparent;
margin-bottom: 4px;
}
.collection-item:hover {
background: #F5F7FA;
}
.collection-item.active {
background: #E6F7FF;
border-left-color: #1890ff;
}
.collection-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.collection-count {
font-size: 12px;
color: #999;
}
.empty-collections {
text-align: center;
padding: 40px 16px;
color: #BFBFBF;
}
/* 右侧主内容区 */
.main-content {
flex: 1;
background: #fff;
border-radius: 12px;
padding: 20px;
min-height: calc(100vh - 160px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
/* 套餐信息区 */
.collection-info {
margin-bottom: 20px;
}
.collection-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0 0 12px;
}
.collection-description {
background: #FAFAFA;
border-radius: 8px;
padding: 12px 16px;
}
.desc-text {
font-size: 14px;
color: #666;
line-height: 1.6;
max-height: 44px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.desc-text.expanded {
max-height: 500px;
}
.expand-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
padding: 0;
border: none;
background: none;
color: #1890ff;
font-size: 13px;
cursor: pointer;
}
.expand-btn .anticon {
transition: transform 0.3s ease;
}
.expand-btn .anticon.rotated {
transform: rotate(180deg);
}
/* 筛选区 */
.filter-section {
background: #FAFAFA;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.filter-row + .filter-row {
margin-top: 12px;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 14px;
color: #666;
flex-shrink: 0;
}
.grade-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.grade-tag {
display: inline-flex;
align-items: center;
padding: 4px 14px;
border-radius: 16px;
font-size: 13px;
color: #666;
background: #F5F5F5;
cursor: pointer;
transition: all 0.2s ease;
}
.grade-tag:hover {
background: #E6F7FF;
color: #1890ff;
}
.grade-tag.active {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
color: #fff;
}
.grade-tag .count {
font-size: 12px;
margin-left: 2px;
opacity: 0.8;
}
.search-group {
margin-left: auto;
}
/* 课程包网格 */
.packages-section {
min-height: 400px;
}
.packages-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
}
/* 空状态 */
.empty-packages {
text-align: center;
padding: 80px 20px;
}
.empty-icon {
font-size: 64px;
color: #D9D9D9;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
color: #666;
margin: 0 0 8px;
}
.empty-hint {
font-size: 14px;
color: #999;
margin: 0;
}
/* 未选择套餐 */
.no-selection {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 500px;
color: #BFBFBF;
}
.no-selection-icon {
font-size: 64px;
margin-bottom: 16px;
}
/* 详情抽屉样式 */
.cover-preview {
margin-top: 24px;
}
.cover-preview h4,
.lessons-section h4 {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.lessons-section {
margin-top: 24px;
}
.lesson-item {
display: flex;
align-items: center;
gap: 8px;
}
.lesson-name {
font-size: 14px;
color: #333;
}
/* 响应式 */
@media (max-width: 768px) {
.course-center-page {
flex-direction: column;
padding: 12px;
}
.collection-sidebar {
width: 100%;
order: 1;
}
.main-content {
order: 2;
}
.packages-grid {
grid-template-columns: 1fr;
}
.filter-row {
flex-direction: column;
align-items: flex-start;
}
.search-group {
margin-left: 0;
width: 100%;
}
.search-group :deep(.ant-input-search) {
width: 100% !important;
}
}
</style>

View File

@ -0,0 +1,251 @@
<template>
<div class="package-card" @click="handleClick">
<!-- 封面区域 -->
<div class="cover-wrapper">
<img
v-if="pkg.coverImagePath"
:src="getImageUrl(pkg.coverImagePath)"
class="cover-image"
alt="课程包封面"
/>
<div v-else class="cover-placeholder">
<BookFilled class="placeholder-icon" />
<span class="placeholder-text">精彩绘本</span>
</div>
</div>
<!-- 内容区域 -->
<div class="content-wrapper">
<h3 class="package-name" :title="pkg.name">{{ pkg.name }}</h3>
<p v-if="pkg.pictureBookName" class="book-name">
<BookOutlined /> {{ pkg.pictureBookName }}
</p>
<!-- 年级标签行 -->
<div class="tag-row grade-row">
<span class="grade-tag">
{{ gradeText }}
</span>
</div>
<!-- 主题标签行 -->
<div v-if="pkg.themeName" class="tag-row theme-row">
<span class="theme-tag">
{{ pkg.themeName }}
</span>
</div>
<!-- 统计信息 -->
<div class="meta-row">
<span class="meta-item">
<ClockCircleOutlined />
{{ pkg.durationMinutes || 30 }}分钟
</span>
<span class="meta-item">
<TeamOutlined />
{{ pkg.usageCount || 0 }}
</span>
</div>
<!-- 操作按钮学校端只查看详情 -->
<button class="action-btn" @click.stop="handleView">
<EyeOutlined />
<span>查看详情</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
BookOutlined,
BookFilled,
ClockCircleOutlined,
TeamOutlined,
EyeOutlined,
} from '@ant-design/icons-vue';
import type { CoursePackage } from '@/api/course-center';
const props = defineProps<{
pkg: CoursePackage;
}>();
const emit = defineEmits<{
(e: 'click', pkg: CoursePackage): void;
(e: 'view', pkg: CoursePackage): void;
}>();
//
const gradeText = computed(() => {
const grades = props.pkg.gradeTags || [];
return grades.join(' · ');
});
// URL
const getImageUrl = (path: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
return `${import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000'}${path}`;
};
const handleClick = () => {
emit('click', props.pkg);
};
const handleView = () => {
emit('view', props.pkg);
};
</script>
<style scoped>
.package-card {
background: #fff;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 2px solid transparent;
}
.package-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.15);
border-color: #1890ff;
}
/* 封面区域 */
.cover-wrapper {
position: relative;
height: 160px;
overflow: hidden;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.package-card:hover .cover-image {
transform: scale(1.05);
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
}
.placeholder-icon {
font-size: 48px;
color: #1890ff;
margin-bottom: 8px;
}
.placeholder-text {
font-size: 14px;
color: #1890ff;
font-weight: 500;
}
/* 内容区域 */
.content-wrapper {
padding: 12px;
}
.package-name {
font-size: 15px;
font-weight: 600;
color: #333;
margin: 0 0 4px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-name {
font-size: 13px;
color: #888;
margin: 0 0 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
}
/* 标签行 */
.tag-row {
margin-bottom: 6px;
}
.grade-tag {
display: inline-block;
padding: 2px 10px;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
color: #1890ff;
font-size: 12px;
border-radius: 4px;
border: 1px solid #91d5ff;
}
.theme-tag {
display: inline-block;
padding: 2px 10px;
background: #f6ffed;
color: #52c41a;
font-size: 12px;
border-radius: 4px;
border: 1px solid #b7eb8f;
}
/* 统计信息 */
.meta-row {
display: flex;
gap: 12px;
padding-top: 8px;
border-top: 1px dashed #EEE;
margin-bottom: 10px;
}
.meta-item {
font-size: 12px;
color: #999;
display: flex;
align-items: center;
gap: 4px;
}
/* 操作按钮 */
.action-btn {
width: 100%;
padding: 8px 16px;
border: none;
border-radius: 20px;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.3s ease;
}
.action-btn:hover {
background: linear-gradient(135deg, #096dd9 0%, #1890ff 100%);
transform: scale(1.02);
}
</style>

View File

@ -0,0 +1,580 @@
<template>
<div class="course-center-page">
<!-- 左侧套餐列表 -->
<aside class="collection-sidebar">
<div class="sidebar-header">
<h3>课程套餐</h3>
</div>
<a-spin :spinning="loadingCollections">
<div class="collection-list">
<div
v-for="collection in collections"
:key="collection.id"
:class="['collection-item', { active: selectedCollectionId === collection.id }]"
@click="selectCollection(collection)"
>
<div class="collection-name">{{ collection.name }}</div>
<div class="collection-count">{{ collection.packageCount || 0 }}个课程包</div>
</div>
<div v-if="!loadingCollections && collections.length === 0" class="empty-collections">
<InboxOutlined />
<p>暂无可用套餐</p>
</div>
</div>
</a-spin>
</aside>
<!-- 右侧主内容区 -->
<main class="main-content">
<template v-if="selectedCollection">
<!-- 套餐信息区 -->
<section class="collection-info">
<h2 class="collection-title">{{ selectedCollection.name }}</h2>
<div v-if="selectedCollection.description" class="collection-description">
<div ref="descRef" :class="['desc-text', { expanded: descExpanded }]">
{{ selectedCollection.description }}
</div>
<button
v-if="showExpandBtn"
class="expand-btn"
@click="descExpanded = !descExpanded"
>
{{ descExpanded ? '收起' : '展开更多' }}
<DownOutlined :class="{ rotated: descExpanded }" />
</button>
</div>
</section>
<!-- 筛选区 -->
<section class="filter-section">
<div class="filter-row">
<!-- 年级筛选标签形式 -->
<div class="filter-group">
<span class="filter-label">年级</span>
<div class="grade-tags">
<span
:class="['grade-tag', { active: !selectedGrade }]"
@click="selectedGrade = ''"
>
全部
</span>
<span
v-for="grade in filterMeta.grades"
:key="grade.label"
:class="['grade-tag', { active: selectedGrade === grade.label }]"
@click="selectedGrade = grade.label"
>
{{ grade.label }}
<span class="count">({{ grade.count }})</span>
</span>
</div>
</div>
</div>
<div class="filter-row">
<!-- 主题筛选 -->
<div class="filter-group">
<span class="filter-label">主题</span>
<a-select
v-model:value="selectedThemeId"
placeholder="全部主题"
style="width: 180px"
allowClear
@change="loadPackages"
>
<a-select-option :value="undefined">全部主题</a-select-option>
<a-select-option
v-for="theme in filterMeta.themes"
:key="theme.id"
:value="theme.id"
>
{{ theme.name }} ({{ theme.count }})
</a-select-option>
</a-select>
</div>
<!-- 搜索 -->
<div class="filter-group search-group">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索课程包..."
style="width: 220px"
allowClear
@search="loadPackages"
/>
</div>
</div>
</section>
<!-- 课程包网格 -->
<section class="packages-section">
<a-spin :spinning="loadingPackages">
<div v-if="packages.length > 0" class="packages-grid">
<CoursePackageCard
v-for="pkg in packages"
:key="pkg.id"
:pkg="pkg"
@click="handlePackageClick"
@prepare="handlePrepare"
/>
</div>
<div v-else class="empty-packages">
<InboxOutlined class="empty-icon" />
<p class="empty-text">暂无符合条件的课程包</p>
<p class="empty-hint">试试调整筛选条件</p>
</div>
</a-spin>
</section>
</template>
<!-- 未选择套餐时的占位 -->
<div v-else class="no-selection">
<BookOutlined class="no-selection-icon" />
<p>请在左侧选择一个课程套餐</p>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
InboxOutlined,
DownOutlined,
BookOutlined,
} from '@ant-design/icons-vue';
import {
getCollections,
getPackages,
getFilterMeta,
type CourseCollection,
type CoursePackage,
type FilterMetaResponse,
} from '@/api/course-center';
import CoursePackageCard from './components/CoursePackageCard.vue';
const router = useRouter();
//
const collections = ref<CourseCollection[]>([]);
const loadingCollections = ref(false);
const selectedCollectionId = ref<number | null>(null);
const selectedCollection = computed(() =>
collections.value.find(c => c.id === selectedCollectionId.value)
);
//
const filterMeta = ref<FilterMetaResponse>({ grades: [], themes: [] });
//
const packages = ref<CoursePackage[]>([]);
const loadingPackages = ref(false);
//
const selectedGrade = ref('');
const selectedThemeId = ref<number | undefined>(undefined);
const searchKeyword = ref('');
//
const descRef = ref<HTMLElement | null>(null);
const descExpanded = ref(false);
const showExpandBtn = ref(false);
//
const loadCollections = async () => {
loadingCollections.value = true;
try {
const data = await getCollections();
collections.value = data || [];
//
if (collections.value.length > 0 && !selectedCollectionId.value) {
selectCollection(collections.value[0]);
}
} catch (error: any) {
message.error(error.message || '获取套餐列表失败');
} finally {
loadingCollections.value = false;
}
};
//
const selectCollection = async (collection: CourseCollection) => {
selectedCollectionId.value = collection.id;
//
selectedGrade.value = '';
selectedThemeId.value = undefined;
searchKeyword.value = '';
descExpanded.value = false;
//
await Promise.all([
loadFilterMeta(),
loadPackages(),
]);
//
nextTick(() => {
checkDescHeight();
});
};
//
const checkDescHeight = () => {
if (descRef.value) {
showExpandBtn.value = descRef.value.scrollHeight > descRef.value.clientHeight;
}
};
//
const loadFilterMeta = async () => {
if (!selectedCollectionId.value) return;
try {
filterMeta.value = await getFilterMeta(selectedCollectionId.value);
} catch (error) {
console.error('获取筛选元数据失败', error);
filterMeta.value = { grades: [], themes: [] };
}
};
//
const loadPackages = async () => {
if (!selectedCollectionId.value) return;
loadingPackages.value = true;
try {
packages.value = await getPackages(selectedCollectionId.value, {
grade: selectedGrade.value || undefined,
themeId: selectedThemeId.value,
keyword: searchKeyword.value || undefined,
});
} catch (error: any) {
message.error(error.message || '获取课程包列表失败');
packages.value = [];
} finally {
loadingPackages.value = false;
}
};
//
const handlePackageClick = (pkg: CoursePackage) => {
router.push(`/teacher/courses/${pkg.id}`);
};
//
const handlePrepare = (pkg: CoursePackage) => {
router.push(`/teacher/courses/${pkg.id}/prepare`);
};
//
watch(selectedGrade, () => {
loadPackages();
});
onMounted(() => {
loadCollections();
});
</script>
<style scoped>
.course-center-page {
display: flex;
min-height: calc(100vh - 120px);
background: #F5F7FA;
gap: 20px;
padding: 20px;
}
/* 左侧套餐列表 */
.collection-sidebar {
width: 220px;
flex-shrink: 0;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #F0F0F0;
}
.sidebar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.collection-list {
padding: 8px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.collection-item {
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border-left: 3px solid transparent;
margin-bottom: 4px;
}
.collection-item:hover {
background: #FFF7E6;
}
.collection-item.active {
background: #FFF7E6;
border-left-color: #FF8C42;
}
.collection-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.collection-count {
font-size: 12px;
color: #999;
}
.empty-collections {
text-align: center;
padding: 40px 20px;
color: #BFBFBF;
}
.empty-collections .anticon {
font-size: 32px;
margin-bottom: 8px;
}
/* 右侧主内容 */
.main-content {
flex: 1;
min-width: 0;
}
/* 套餐信息区 */
.collection-info {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.collection-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0 0 12px;
}
.collection-description {
background: #FAFAFA;
border-radius: 8px;
padding: 12px 16px;
}
.desc-text {
font-size: 14px;
color: #666;
line-height: 1.6;
max-height: 44px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.desc-text.expanded {
max-height: 500px;
}
.expand-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
padding: 0;
border: none;
background: none;
color: #FF8C42;
font-size: 13px;
cursor: pointer;
}
.expand-btn .anticon {
transition: transform 0.3s ease;
}
.expand-btn .anticon.rotated {
transform: rotate(180deg);
}
/* 筛选区 */
.filter-section {
background: #fff;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.filter-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.filter-row + .filter-row {
margin-top: 12px;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 14px;
color: #666;
flex-shrink: 0;
}
.grade-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.grade-tag {
display: inline-flex;
align-items: center;
padding: 4px 14px;
border-radius: 16px;
font-size: 13px;
color: #666;
background: #F5F5F5;
cursor: pointer;
transition: all 0.2s ease;
}
.grade-tag:hover {
background: #FFF7E6;
color: #FF8C42;
}
.grade-tag.active {
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);
color: #fff;
}
.grade-tag .count {
font-size: 12px;
margin-left: 2px;
opacity: 0.8;
}
.search-group {
margin-left: auto;
}
/* 课程包网格 */
.packages-section {
background: #fff;
border-radius: 12px;
padding: 20px;
min-height: 400px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.packages-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
}
/* 空状态 */
.empty-packages {
text-align: center;
padding: 80px 20px;
}
.empty-icon {
font-size: 64px;
color: #D9D9D9;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
color: #666;
margin: 0 0 8px;
}
.empty-hint {
font-size: 14px;
color: #999;
margin: 0;
}
/* 未选择套餐 */
.no-selection {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 500px;
background: #fff;
border-radius: 12px;
color: #BFBFBF;
}
.no-selection-icon {
font-size: 64px;
margin-bottom: 16px;
}
/* 响应式 */
@media (max-width: 768px) {
.course-center-page {
flex-direction: column;
padding: 12px;
}
.collection-sidebar {
width: 100%;
order: 1;
}
.main-content {
order: 2;
}
.packages-grid {
grid-template-columns: 1fr;
}
.filter-row {
flex-direction: column;
align-items: flex-start;
}
.search-group {
margin-left: 0;
width: 100%;
}
.search-group :deep(.ant-input-search) {
width: 100% !important;
}
}
</style>

View File

@ -0,0 +1,259 @@
<template>
<div class="package-card" @click="handleClick">
<!-- 封面区域 -->
<div class="cover-wrapper">
<img
v-if="pkg.coverImagePath"
:src="getImageUrl(pkg.coverImagePath)"
class="cover-image"
alt="课程包封面"
/>
<div v-else class="cover-placeholder">
<BookFilled class="placeholder-icon" />
<span class="placeholder-text">精彩绘本</span>
</div>
</div>
<!-- 内容区域 -->
<div class="content-wrapper">
<h3 class="package-name" :title="pkg.name">{{ pkg.name }}</h3>
<p v-if="pkg.pictureBookName" class="book-name">
<BookOutlined /> {{ pkg.pictureBookName }}
</p>
<!-- 年级标签行 -->
<div class="tag-row grade-row">
<span class="grade-tag">
{{ gradeText }}
</span>
</div>
<!-- 主题标签行 -->
<div v-if="pkg.themeName" class="tag-row theme-row">
<span class="theme-tag">
{{ pkg.themeName }}
</span>
</div>
<!-- 统计信息 -->
<div class="meta-row">
<span class="meta-item">
<ClockCircleOutlined />
{{ pkg.durationMinutes || 30 }}分钟
</span>
<span class="meta-item">
<TeamOutlined />
{{ pkg.usageCount || 0 }}
</span>
</div>
<!-- 操作按钮 -->
<button class="action-btn" @click.stop="handlePrepare">
<EditOutlined />
<span>开始备课</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
BookOutlined,
BookFilled,
ClockCircleOutlined,
TeamOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import type { CoursePackage } from '@/api/course-center';
const props = defineProps<{
pkg: CoursePackage;
}>();
const emit = defineEmits<{
(e: 'click', pkg: CoursePackage): void;
(e: 'prepare', pkg: CoursePackage): void;
}>();
//
const gradeText = computed(() => {
const grades = props.pkg.gradeTags || [];
return grades.join(' · ');
});
// URL
const getImageUrl = (path: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
return `${import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000'}${path}`;
};
const handleClick = () => {
emit('click', props.pkg);
};
const handlePrepare = () => {
emit('prepare', props.pkg);
};
</script>
<style scoped>
.package-card {
background: #fff;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 2px solid transparent;
}
.package-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(255, 140, 66, 0.15);
border-color: #FF8C42;
}
/* 封面区域 */
.cover-wrapper {
position: relative;
height: 160px;
overflow: hidden;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.package-card:hover .cover-image {
transform: scale(1.05);
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #FFE4C9 0%, #FFF0E0 100%);
}
.placeholder-icon {
font-size: 48px;
color: #FF8C42;
margin-bottom: 8px;
}
.placeholder-text {
font-size: 14px;
color: #FF8C42;
font-weight: 500;
}
/* 内容区域 */
.content-wrapper {
padding: 12px;
}
.package-name {
font-size: 15px;
font-weight: 600;
color: #333;
margin: 0 0 4px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-name {
font-size: 13px;
color: #888;
margin: 0 0 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
}
/* 标签行 */
.tag-row {
margin-bottom: 6px;
}
.grade-row {
/* 年级标签样式 */
}
.grade-tag {
display: inline-block;
padding: 2px 10px;
background: linear-gradient(135deg, #FFF7E6 0%, #FFECD9 100%);
color: #D46B08;
font-size: 12px;
border-radius: 4px;
border: 1px solid #FFD591;
}
.theme-row {
/* 主题标签样式 */
}
.theme-tag {
display: inline-block;
padding: 2px 10px;
background: #E6F7FF;
color: #096DD9;
font-size: 12px;
border-radius: 4px;
border: 1px solid #91D5FF;
}
/* 统计信息 */
.meta-row {
display: flex;
gap: 12px;
padding-top: 8px;
border-top: 1px dashed #EEE;
margin-bottom: 10px;
}
.meta-item {
font-size: 12px;
color: #999;
display: flex;
align-items: center;
gap: 4px;
}
/* 操作按钮 */
.action-btn {
width: 100%;
padding: 8px 16px;
border: none;
border-radius: 20px;
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.3s ease;
}
.action-btn:hover {
background: linear-gradient(135deg, #E67635 0%, #FF8C42 100%);
transform: scale(1.02);
}
</style>

View File

@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.common.security.SecurityUtils;
import com.reading.platform.dto.response.CourseResponse;
import com.reading.platform.dto.response.LessonTagResponse;
import com.reading.platform.dto.response.SchoolCourseResponse;
import com.reading.platform.entity.CourseLesson;
@ -71,12 +72,14 @@ public class SchoolCourseController {
}
@GetMapping("/{id}")
@Operation(summary = "获取课程详情")
public Result<SchoolCourseResponse> getSchoolCourse(@PathVariable Long id) {
@Operation(summary = "获取课程详情(包含课程环节、介绍、资源等完整信息)")
public Result<CourseResponse> getSchoolCourse(@PathVariable Long id) {
log.info("获取课程详情id={}", id);
Long tenantId = SecurityUtils.getCurrentTenantId();
CoursePackage course = courseService.getCourseByIdWithTenantCheck(id, tenantId);
return Result.success(SchoolCourseResponse.toSchoolCourseResponse(course));
// 验证权限
courseService.getCourseByIdWithTenantCheck(id, tenantId);
// 返回完整详情与教师端一致
return Result.success(courseService.getCourseByIdWithLessons(id));
}

View File

@ -8,6 +8,7 @@ import com.reading.platform.dto.request.RenewRequest;
import com.reading.platform.dto.response.CourseCollectionResponse;
import com.reading.platform.dto.response.CourseResponse;
import com.reading.platform.dto.response.CoursePackageResponse;
import com.reading.platform.dto.response.PackageFilterMetaResponse;
import com.reading.platform.dto.response.PackageInfoResponse;
import com.reading.platform.dto.response.PackageUsageResponse;
import com.reading.platform.entity.Tenant;
@ -46,8 +47,19 @@ public class SchoolPackageController {
@GetMapping("/{collectionId}/packages")
@Operation(summary = "获取课程套餐下的课程包列表")
@RequireRole({UserRole.SCHOOL, UserRole.TEACHER})
public Result<List<CoursePackageResponse>> getPackagesByCollection(@PathVariable Long collectionId) {
return Result.success(collectionService.getPackagesByCollection(collectionId));
public Result<List<CoursePackageResponse>> getPackagesByCollection(
@PathVariable Long collectionId,
@RequestParam(required = false) String grade,
@RequestParam(required = false) Long themeId,
@RequestParam(required = false) String keyword) {
return Result.success(collectionService.getPackagesByCollection(collectionId, grade, themeId, keyword));
}
@GetMapping("/{collectionId}/filter-meta")
@Operation(summary = "获取套餐筛选元数据(年级、主题选项)")
@RequireRole({UserRole.SCHOOL, UserRole.TEACHER})
public Result<PackageFilterMetaResponse> getFilterMeta(@PathVariable Long collectionId) {
return Result.success(collectionService.getPackageFilterMeta(collectionId));
}
@PostMapping("/{collectionId}/renew")

View File

@ -81,6 +81,33 @@ public class CoursePackageResponse {
@Schema(description = "排序号(在课程套餐中的顺序)")
private Integer sortOrder;
@Schema(description = "主题ID")
private Long themeId;
@Schema(description = "主题名称")
private String themeName;
@Schema(description = "绘本名称")
private String pictureBookName;
@Schema(description = "封面图片路径")
private String coverImagePath;
@Schema(description = "年级标签(数组)")
private String[] gradeTags;
@Schema(description = "领域标签(数组)")
private String[] domainTags;
@Schema(description = "课程时长(分钟)")
private Integer durationMinutes;
@Schema(description = "使用次数")
private Integer usageCount;
@Schema(description = "平均评分")
private java.math.BigDecimal avgRating;
/**
* 课程包中的课程项
*/

View File

@ -0,0 +1,62 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 套餐筛选元数据响应
* 用于返回套餐下课程包的筛选选项年级主题
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "套餐筛选元数据响应")
public class PackageFilterMetaResponse {
@Schema(description = "年级选项列表")
private List<GradeOption> grades;
@Schema(description = "主题选项列表")
private List<ThemeOption> themes;
/**
* 年级选项
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "年级选项")
public static class GradeOption {
@Schema(description = "年级名称")
private String label;
@Schema(description = "该年级下的课程包数量")
private Integer count;
}
/**
* 主题选项
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "主题选项")
public static class ThemeOption {
@Schema(description = "主题ID")
private Long id;
@Schema(description = "主题名称")
private String name;
@Schema(description = "该主题下的课程包数量")
private Integer count;
}
}

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.reading.platform.dto.response.CourseCollectionResponse;
import com.reading.platform.dto.response.CoursePackageResponse;
import com.reading.platform.dto.response.PackageFilterMetaResponse;
import com.reading.platform.entity.CourseCollection;
import java.time.LocalDate;
@ -34,6 +35,23 @@ public interface CourseCollectionService extends IService<CourseCollection> {
*/
List<CoursePackageResponse> getPackagesByCollection(Long collectionId);
/**
* 获取课程套餐下的课程包列表支持筛选
* @param collectionId 套餐ID
* @param grade 年级筛选
* @param themeId 主题ID筛选
* @param keyword 关键词搜索
* @return 课程包列表
*/
List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, Long themeId, String keyword);
/**
* 获取套餐的筛选元数据年级主题选项
* @param collectionId 套餐ID
* @return 筛选元数据
*/
PackageFilterMetaResponse getPackageFilterMeta(Long collectionId);
/**
* 创建课程套餐
*/

View File

@ -10,6 +10,7 @@ import com.reading.platform.common.exception.BusinessException;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.dto.response.CourseCollectionResponse;
import com.reading.platform.dto.response.CoursePackageResponse;
import com.reading.platform.dto.response.PackageFilterMetaResponse;
import com.reading.platform.entity.*;
import com.reading.platform.mapper.*;
import com.reading.platform.service.CourseCollectionService;
@ -22,7 +23,11 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -38,8 +43,8 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
private final CoursePackageMapper packageMapper;
private final TenantPackageMapper tenantPackageMapper;
private final CoursePackageCourseMapper packageCoursePackageMapper;
private final CoursePackageMapper courseMapper;
private final CourseLessonService courseLessonService;
private final ThemeMapper themeMapper;
/**
* 查询租户的课程套餐列表
@ -199,6 +204,186 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
return result;
}
/**
* 获取课程套餐下的课程包列表支持筛选
*/
@Override
public List<CoursePackageResponse> getPackagesByCollection(Long collectionId, String grade, Long themeId, String keyword) {
log.info("获取课程套餐的课程包列表筛选collectionId={}, grade={}, themeId={}, keyword={}", collectionId, grade, themeId, keyword);
// 查询关联关系
List<CourseCollectionPackage> associations = collectionPackageMapper.selectList(
new LambdaQueryWrapper<CourseCollectionPackage>()
.eq(CourseCollectionPackage::getCollectionId, collectionId)
.orderByAsc(CourseCollectionPackage::getSortOrder)
);
if (associations.isEmpty()) {
return new ArrayList<>();
}
// 获取课程包ID列表
List<Long> packageIds = associations.stream()
.map(CourseCollectionPackage::getPackageId)
.collect(Collectors.toList());
// 构建查询条件
LambdaQueryWrapper<CoursePackage> wrapper = new LambdaQueryWrapper<CoursePackage>()
.in(CoursePackage::getId, packageIds)
.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode());
// 年级筛选gradeTags JSON 数组格式
if (StringUtils.hasText(grade)) {
wrapper.apply("JSON_CONTAINS(grade_tags, {0})", "\"" + grade + "\"");
}
// 主题筛选
if (themeId != null) {
wrapper.eq(CoursePackage::getThemeId, themeId);
}
// 关键词搜索
if (StringUtils.hasText(keyword)) {
wrapper.and(w -> w
.like(CoursePackage::getName, keyword)
.or()
.like(CoursePackage::getPictureBookName, keyword)
);
}
List<CoursePackage> packages = packageMapper.selectList(wrapper);
// 获取所有主题信息批量查询优化
Set<Long> themeIds = packages.stream()
.map(CoursePackage::getThemeId)
.filter(id -> id != null)
.collect(Collectors.toSet());
Map<Long, String> themeNameMap = new HashMap<>();
if (!themeIds.isEmpty()) {
List<Theme> themes = themeMapper.selectBatchIds(themeIds);
themeNameMap = themes.stream()
.collect(Collectors.toMap(Theme::getId, Theme::getName));
}
// 转换为响应对象
final Map<Long, String> finalThemeNameMap = themeNameMap;
List<CoursePackageResponse> result = packages.stream()
.map(pkg -> {
CoursePackageResponse response = toPackageResponse(pkg);
// 设置主题名称
if (pkg.getThemeId() != null) {
response.setThemeId(pkg.getThemeId());
response.setThemeName(finalThemeNameMap.get(pkg.getThemeId()));
}
// 设置排序号
associations.stream()
.filter(a -> a.getPackageId().equals(pkg.getId()))
.findFirst()
.ifPresent(a -> response.setSortOrder(a.getSortOrder()));
return response;
})
.collect(Collectors.toList());
log.info("筛选后查询到{}个课程包", result.size());
return result;
}
/**
* 获取套餐的筛选元数据
*/
@Override
public PackageFilterMetaResponse getPackageFilterMeta(Long collectionId) {
log.info("获取套餐筛选元数据collectionId={}", collectionId);
// 查询套餐下所有课程包
List<CourseCollectionPackage> associations = collectionPackageMapper.selectList(
new LambdaQueryWrapper<CourseCollectionPackage>()
.eq(CourseCollectionPackage::getCollectionId, collectionId)
);
if (associations.isEmpty()) {
return PackageFilterMetaResponse.builder()
.grades(new ArrayList<>())
.themes(new ArrayList<>())
.build();
}
List<Long> packageIds = associations.stream()
.map(CourseCollectionPackage::getPackageId)
.collect(Collectors.toList());
List<CoursePackage> packages = packageMapper.selectList(
new LambdaQueryWrapper<CoursePackage>()
.in(CoursePackage::getId, packageIds)
.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode())
);
// 统计年级分布
Map<String, Integer> gradeCountMap = new HashMap<>();
for (CoursePackage pkg : packages) {
String[] grades = parseGradeTags(pkg.getGradeTags());
for (String g : grades) {
gradeCountMap.merge(g, 1, Integer::sum);
}
}
// 按顺序生成年级选项
List<String> gradeOrder = List.of("小班", "中班", "大班");
List<PackageFilterMetaResponse.GradeOption> grades = gradeOrder.stream()
.filter(gradeCountMap::containsKey)
.map(grade -> PackageFilterMetaResponse.GradeOption.builder()
.label(grade)
.count(gradeCountMap.get(grade))
.build())
.collect(Collectors.toList());
// 统计主题分布
Map<Long, Integer> themeCountMap = new HashMap<>();
Set<Long> themeIds = new HashSet<>();
for (CoursePackage pkg : packages) {
if (pkg.getThemeId() != null) {
themeCountMap.merge(pkg.getThemeId(), 1, Integer::sum);
themeIds.add(pkg.getThemeId());
}
}
// 批量查询主题名称
List<PackageFilterMetaResponse.ThemeOption> themes = new ArrayList<>();
if (!themeIds.isEmpty()) {
List<Theme> themeList = themeMapper.selectBatchIds(themeIds);
themes = themeList.stream()
.filter(t -> themeCountMap.containsKey(t.getId()))
.map(t -> PackageFilterMetaResponse.ThemeOption.builder()
.id(t.getId())
.name(t.getName())
.count(themeCountMap.get(t.getId()))
.build())
.collect(Collectors.toList());
}
return PackageFilterMetaResponse.builder()
.grades(grades)
.themes(themes)
.build();
}
/**
* 解析年级标签
*/
private String[] parseGradeTags(String gradeTags) {
if (!StringUtils.hasText(gradeTags)) {
return new String[0];
}
try {
if (gradeTags.trim().startsWith("[")) {
return JSON.parseArray(gradeTags, String.class).toArray(new String[0]);
}
return gradeTags.split(",");
} catch (Exception e) {
return new String[0];
}
}
/**
* 创建课程套餐
*/
@ -675,8 +860,17 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
.discountPrice(null)
.discountType(null)
.gradeLevels(gradeLevelsArray)
.gradeTags(gradeLevelsArray) // 同时设置 gradeTags 字段供前端使用
.courseCount(lessons.size()) // 使用课程环节数量
.status(pkg.getStatus())
// 新增字段封面绘本主题
.coverImagePath(pkg.getCoverImagePath())
.pictureBookName(pkg.getPictureBookName())
.themeId(pkg.getThemeId())
.durationMinutes(pkg.getDurationMinutes())
.usageCount(pkg.getUsageCount())
.avgRating(pkg.getAvgRating())
// 审核相关
.submittedAt(pkg.getSubmittedAt())
.submittedBy(pkg.getSubmittedBy())
.reviewedAt(pkg.getReviewedAt())