Compare commits
No commits in common. "e8219e5aab3edb64f004092cdfe8240b6cf10aa1" and "2e0e5db06b3821c3c6736cf5cf25eed4e52aab07" have entirely different histories.
e8219e5aab
...
2e0e5db06b
@ -1,386 +0,0 @@
|
|||||||
# 课程中心重构设计(教师端 & 学校端)
|
|
||||||
|
|
||||||
> 创建时间:2026-03-21
|
|
||||||
> 状态:✅ 已实现
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、背景与目标
|
|
||||||
|
|
||||||
### 1.1 重构背景
|
|
||||||
|
|
||||||
原有学校端"课程管理"模块功能单一:
|
|
||||||
- 仅展示课程包列表和基本信息
|
|
||||||
- 课程详情页展示数据不完整
|
|
||||||
- 缺少课程介绍、排课参考、环创建设等内容
|
|
||||||
- 与教师端课程中心存在功能重复,维护成本高
|
|
||||||
|
|
||||||
### 1.2 重构目标
|
|
||||||
|
|
||||||
1. **统一体验**:学校端课程中心与教师端保持一致的浏览体验
|
|
||||||
2. **完整展示**:课程详情页展示完整信息(课程配置、排课参考、环创建设等)
|
|
||||||
3. **代码复用**:两端共享组件和 API 层,降低维护成本
|
|
||||||
4. **角色适配**:学校端移除备课/授课功能,仅保留查看能力
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、功能对比
|
|
||||||
|
|
||||||
### 2.1 功能差异矩阵
|
|
||||||
|
|
||||||
| 功能点 | 教师端 | 学校端 | 说明 |
|
|
||||||
|-------|:------:|:------:|------|
|
|
||||||
| 套餐列表浏览 | ✅ | ✅ | 左侧套餐列表 |
|
|
||||||
| 课程包列表 | ✅ | ✅ | 右侧网格展示 |
|
|
||||||
| 年级/主题筛选 | ✅ | ✅ | 标签+下拉筛选 |
|
|
||||||
| 课程包搜索 | ✅ | ✅ | 关键词搜索 |
|
|
||||||
| 课程包详情查看 | ✅ | ✅ | 完整详情页 |
|
|
||||||
| 备课模式 | ✅ | ❌ | 学校端无 |
|
|
||||||
| 授课模式 | ✅ | ❌ | 学校端无 |
|
|
||||||
| 预约上课 | ✅ | ❌ | 学校端无 |
|
|
||||||
| 收藏课程 | ✅ | ❌ | 学校端暂无 |
|
|
||||||
| 创建校本版本 | ✅ | ❌ | 学校端暂无 |
|
|
||||||
|
|
||||||
### 2.2 数据展示对比
|
|
||||||
|
|
||||||
| 数据项 | 教师端详情 | 学校端详情(重构前) | 学校端详情(重构后) |
|
|
||||||
|-------|:----------:|:------------------:|:------------------:|
|
|
||||||
| 基本信息 | ✅ | ✅ | ✅ |
|
|
||||||
| 使用统计 | ✅ | ✅ | ✅ |
|
|
||||||
| 版本记录 | ✅ | ✅ | ✅ |
|
|
||||||
| 课程介绍(8项) | ✅ | ❌ | ✅ |
|
|
||||||
| 排课计划参考 | ✅ | ❌ | ✅ |
|
|
||||||
| 环创建设 | ✅ | ❌ | ✅ |
|
|
||||||
| 课程配置(lessons) | ✅ | ❌ | ✅ |
|
|
||||||
| 数字资源 | ✅ | ❌ | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、架构设计
|
|
||||||
|
|
||||||
### 3.1 前端组件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
reading-platform-frontend/src/views/
|
|
||||||
├── school/
|
|
||||||
│ └── courses-new/ # 学校端课程中心(新增)
|
|
||||||
│ ├── CourseCenterView.vue # 课程中心主页面
|
|
||||||
│ └── components/
|
|
||||||
│ └── CoursePackageCard.vue # 课程包卡片组件
|
|
||||||
│
|
|
||||||
├── teacher/
|
|
||||||
│ └── courses-new/ # 教师端课程中心(新增)
|
|
||||||
│ ├── CourseCenterView.vue # 课程中心主页面
|
|
||||||
│ └── components/
|
|
||||||
│ └── CoursePackageCard.vue # 课程包卡片组件
|
|
||||||
│
|
|
||||||
└── [共享] courses/CourseDetailView.vue # 课程详情页(两端共用)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 API 层设计
|
|
||||||
|
|
||||||
```
|
|
||||||
reading-platform-frontend/src/api/
|
|
||||||
├── course-center.ts # 课程中心通用 API(新增)
|
|
||||||
│ ├── getCollections() # 获取套餐列表
|
|
||||||
│ ├── getPackages() # 获取课程包列表
|
|
||||||
│ └── getFilterMeta() # 获取筛选元数据
|
|
||||||
│
|
|
||||||
├── school.ts # 学校端专用 API
|
|
||||||
│ └── getSchoolCourse(id) # 获取课程详情
|
|
||||||
│
|
|
||||||
└── teacher.ts # 教师端专用 API
|
|
||||||
└── getTeacherCourse(id) # 获取课程详情
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 后端 API 设计
|
|
||||||
|
|
||||||
```
|
|
||||||
# 学校端
|
|
||||||
GET /api/v1/school/packages # 获取已授权套餐列表
|
|
||||||
GET /api/v1/school/packages/{collectionId}/packages # 获取套餐下的课程包
|
|
||||||
GET /api/v1/school/packages/{packageId}/filter-meta # 获取筛选元数据
|
|
||||||
GET /api/v1/school/courses/{id} # 获取课程详情 → 返回 CourseResponse
|
|
||||||
|
|
||||||
# 教师端
|
|
||||||
GET /api/v1/teacher/packages # 获取已授权套餐列表
|
|
||||||
GET /api/v1/teacher/packages/{collectionId}/packages # 获取套餐下的课程包
|
|
||||||
GET /api/v1/teacher/packages/{packageId}/filter-meta # 获取筛选元数据
|
|
||||||
GET /api/v1/teacher/courses/{id} # 获取课程详情 → 返回 CourseResponse
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、页面布局设计
|
|
||||||
|
|
||||||
### 4.1 课程中心主页面
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 课程中心 │
|
|
||||||
├──────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌────────────┬───────────────────────────────────────────────────┐ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ 套餐列表 │ 课程包网格 │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ ┌────────┐ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
|
|
||||||
│ │ │小班套餐 │ │ │课程包1│ │课程包2│ │课程包3│ │课程包4│ │ │
|
|
||||||
│ │ │12个课程 │ │ │ │ │ │ │ │ │ │ │ │
|
|
||||||
│ │ └────────┘ │ │ │ │ │ │ │ │ │ │ │
|
|
||||||
│ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
|
|
||||||
│ │ ┌────────┐ │ │ │
|
|
||||||
│ │ │中班套餐 │ │ 筛选条件: │ │
|
|
||||||
│ │ │15个课程 │ │ 年级:[全部] [小班] [中班] [大班] │ │
|
|
||||||
│ │ └────────┘ │ 主题:[下拉选择] │ │
|
|
||||||
│ │ │ 搜索:[关键词搜索______] │ │
|
|
||||||
│ │ ┌────────┐ │ │ │
|
|
||||||
│ │ │大班套餐 │ │ │ │
|
|
||||||
│ │ │18个课程 │ │ │ │
|
|
||||||
│ │ └────────┘ │ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ └────────────┴───────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 课程包详情页(完整版)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ◀ 返回课程中心 │
|
|
||||||
│ │
|
|
||||||
│ 课程包名称 - 课程详情 │
|
|
||||||
├──────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌────────────┬───────────────────────────────────────────────────┐ │
|
|
||||||
│ │ [封面图] │ 基本信息:主题、适用年级、时长等 │ │
|
|
||||||
│ │ │ 使用统计:使用次数、使用教师、平均评分 │ │
|
|
||||||
│ └────────────┴───────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ │
|
|
||||||
│ 【课程介绍】(Tab 形式) │
|
|
||||||
│ ┌────────┬────────┬────────┬────────┬────────┐ │
|
|
||||||
│ │课程简介│课程亮点│课程目标│内容安排│重难点│教学方法│评价方式│注意事项│ │
|
|
||||||
│ └────────┴────────┴────────┴────────┴────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ │
|
|
||||||
│ 【排课计划参考】 │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 时间 │ 课程类型 │ 课程名称 │ 区域活动 │ 备注 │ │
|
|
||||||
│ │ 周一 │ 导入课 │ ... │ ... │ ... │ │
|
|
||||||
│ │ 周二 │ 集体课 │ ... │ ... │ ... │ │
|
|
||||||
│ │ 周三 │ 语言领域 │ ... │ ... │ ... │ │
|
|
||||||
│ │ ... │ ... │ ... │ ... │ ... │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ │
|
|
||||||
│ 【环创建设】 │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 环创建设内容... │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ │
|
|
||||||
│ 【课程配置】(7节课程) │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 1. 导入课 - 10分钟 │ │
|
|
||||||
│ │ 教学目标、教学准备、核心资源、教学环节(步骤列表) │ │
|
|
||||||
│ ├──────────────────────────────────────────────────────────────┤ │
|
|
||||||
│ │ 2. 集体课 - 25分钟 │ │
|
|
||||||
│ │ ... │ │
|
|
||||||
│ ├──────────────────────────────────────────────────────────────┤ │
|
|
||||||
│ │ 3-7. 五大领域课程(语言/科学/健康/社会/艺术) │ │
|
|
||||||
│ │ ... │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ │
|
|
||||||
│ 【数字资源】 │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 视频资源 | 音频资源 | 文档资源 | 图片资源 │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、关键技术实现
|
|
||||||
|
|
||||||
### 5.1 后端:统一返回 CourseResponse
|
|
||||||
|
|
||||||
**修改前(学校端):**
|
|
||||||
```java
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
public Result<SchoolCourseResponse> getSchoolCourse(@PathVariable Long id) {
|
|
||||||
// 返回简化的 SchoolCourseResponse
|
|
||||||
return Result.success(SchoolCourseResponse.toSchoolCourseResponse(course));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改后(学校端):**
|
|
||||||
```java
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
public Result<CourseResponse> getSchoolCourse(@PathVariable Long id) {
|
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
|
||||||
// 验证权限
|
|
||||||
courseService.getCourseByIdWithTenantCheck(id, tenantId);
|
|
||||||
// 返回完整详情(与教师端一致)
|
|
||||||
return Result.success(courseService.getCourseByIdWithLessons(id));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 CourseResponse 完整字段
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class CourseResponse {
|
|
||||||
// 基本信息
|
|
||||||
private Long id;
|
|
||||||
private String name;
|
|
||||||
private String description;
|
|
||||||
private String coverImagePath;
|
|
||||||
private Integer durationMinutes;
|
|
||||||
private String[] gradeTags;
|
|
||||||
private String[] domainTags;
|
|
||||||
|
|
||||||
// 课程介绍(8项)
|
|
||||||
private String introSummary; // 课程简介
|
|
||||||
private String introHighlights; // 课程亮点
|
|
||||||
private String introGoals; // 课程总目标
|
|
||||||
private String introSchedule; // 内容安排
|
|
||||||
private String introKeyPoints; // 重难点
|
|
||||||
private String introMethods; // 教学方法
|
|
||||||
private String introEvaluation; // 评价方式
|
|
||||||
private String introNotes; // 注意事项
|
|
||||||
|
|
||||||
// 排课参考
|
|
||||||
private String scheduleRefData; // JSON 格式的排课计划
|
|
||||||
|
|
||||||
// 环创建设
|
|
||||||
private String environmentConstruction;
|
|
||||||
|
|
||||||
// 课程配置(核心)
|
|
||||||
private List<CourseLessonResponse> courseLessons; // 课程列表,包含步骤
|
|
||||||
|
|
||||||
// 统计数据
|
|
||||||
private Integer usageCount;
|
|
||||||
private Integer teacherCount;
|
|
||||||
private Double avgRating;
|
|
||||||
|
|
||||||
// 版本信息
|
|
||||||
private String version;
|
|
||||||
private LocalDateTime publishedAt;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 前端:筛选元数据响应
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// PackageFilterMetaResponse
|
|
||||||
interface FilterMetaResponse {
|
|
||||||
grades: Array<{
|
|
||||||
label: string; // 年级名称
|
|
||||||
count: number; // 该年级下课程包数量
|
|
||||||
}>;
|
|
||||||
themes: Array<{
|
|
||||||
id: number; // 主题ID
|
|
||||||
name: string; // 主题名称
|
|
||||||
count: number; // 该主题下课程包数量
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、文件变更清单
|
|
||||||
|
|
||||||
### 6.1 前端新增文件
|
|
||||||
|
|
||||||
| 文件路径 | 说明 |
|
|
||||||
|---------|------|
|
|
||||||
| `src/api/course-center.ts` | 课程中心通用 API |
|
|
||||||
| `src/views/school/courses-new/CourseCenterView.vue` | 学校端课程中心页面 |
|
|
||||||
| `src/views/school/courses-new/components/CoursePackageCard.vue` | 课程包卡片组件 |
|
|
||||||
| `src/views/teacher/courses-new/CourseCenterView.vue` | 教师端课程中心页面 |
|
|
||||||
| `src/views/teacher/courses-new/components/CoursePackageCard.vue` | 课程包卡片组件 |
|
|
||||||
|
|
||||||
### 6.2 前端修改文件
|
|
||||||
|
|
||||||
| 文件路径 | 修改内容 |
|
|
||||||
|---------|---------|
|
|
||||||
| `src/router/index.ts` | 课程中心路由指向新组件 |
|
|
||||||
| `src/views/school/LayoutView.vue` | 菜单文案:课程管理 → 课程中心 |
|
|
||||||
| `src/api/school.ts` | 新增 getSchoolCourse 返回 any 类型 |
|
|
||||||
|
|
||||||
### 6.3 后端修改文件
|
|
||||||
|
|
||||||
| 文件路径 | 修改内容 |
|
|
||||||
|---------|---------|
|
|
||||||
| `SchoolCourseController.java` | getSchoolCourse 返回 CourseResponse |
|
|
||||||
| `SchoolPackageController.java` | 新增筛选元数据接口 |
|
|
||||||
| `CoursePackageResponse.java` | 新增 filterMeta 字段 |
|
|
||||||
| `PackageFilterMetaResponse.java` | 新增筛选元数据响应类 |
|
|
||||||
| `CourseCollectionService.java` | 新增 getFilterMeta 方法 |
|
|
||||||
| `CourseCollectionServiceImpl.java` | 实现筛选元数据查询 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、测试验证
|
|
||||||
|
|
||||||
### 7.1 API 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 获取课程详情
|
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
|
||||||
http://localhost:8480/api/v1/school/courses/17 | jq '{
|
|
||||||
has_courseLessons: (.data.courseLessons | length),
|
|
||||||
has_scheduleRefData: (.data.scheduleRefData != null),
|
|
||||||
has_introSummary: (.data.introSummary != null)
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 预期结果
|
|
||||||
{
|
|
||||||
"has_courseLessons": 7, // ✅ 包含课程配置
|
|
||||||
"has_scheduleRefData": true, // ✅ 包含排课参考
|
|
||||||
"has_introSummary": true // ✅ 包含课程介绍
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 前端验证清单
|
|
||||||
|
|
||||||
- [x] 学校端课程中心页面正常加载
|
|
||||||
- [x] 左侧套餐列表正常显示
|
|
||||||
- [x] 右侧课程包网格正常显示
|
|
||||||
- [x] 年级/主题筛选正常工作
|
|
||||||
- [x] 关键词搜索正常工作
|
|
||||||
- [x] 点击课程包跳转详情页
|
|
||||||
- [x] 课程详情页展示完整数据:
|
|
||||||
- [x] 基本信息
|
|
||||||
- [x] 使用统计
|
|
||||||
- [x] 课程介绍(8项)
|
|
||||||
- [x] 排课计划参考
|
|
||||||
- [x] 环创建设
|
|
||||||
- [x] 课程配置(lessons with steps)
|
|
||||||
- [x] 数字资源
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、后续优化方向
|
|
||||||
|
|
||||||
1. **组件复用**:考虑将 CourseCenterView 抽象为通用组件,通过 props 区分角色
|
|
||||||
2. **缓存优化**:套餐列表和筛选元数据可考虑前端缓存
|
|
||||||
3. **学校端增强**:
|
|
||||||
- 添加收藏功能
|
|
||||||
- 添加数据统计导出
|
|
||||||
4. **教师端增强**:
|
|
||||||
- 备课入口优化
|
|
||||||
- 授课入口优化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*本文档创建于 2026-03-21*
|
|
||||||
*实现版本:retirado 分支 commit 3183d1d*
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
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`);
|
|
||||||
}
|
|
||||||
@ -348,24 +348,6 @@ export const getCourseCollections = () =>
|
|||||||
export const getCourseCollectionPackages = (collectionId: number | string) =>
|
export const getCourseCollectionPackages = (collectionId: number | string) =>
|
||||||
http.get<CoursePackageItem[]>(`/v1/school/packages/${collectionId}/packages`);
|
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) =>
|
export const renewCollection = (collectionId: number, data: RenewPackageDto) =>
|
||||||
http.post<void>(`/v1/school/packages/${collectionId}/renew`, data);
|
http.post<void>(`/v1/school/packages/${collectionId}/renew`, data);
|
||||||
@ -451,8 +433,8 @@ export const getSchoolCourseList = (params?: {
|
|||||||
}) =>
|
}) =>
|
||||||
http.get<{ list: Course[]; total: number; pageNum: number; pageSize: number; pages: number }>('/v1/school/courses', { params });
|
http.get<{ list: Course[]; total: number; pageNum: number; pageSize: number; pages: number }>('/v1/school/courses', { params });
|
||||||
|
|
||||||
export const getSchoolCourse = (id: number | string): Promise<any> =>
|
export const getSchoolCourse = (id: number) =>
|
||||||
http.get(`/v1/school/courses/${id}`) as any;
|
http.get<Course>(`/v1/school/courses/${id}`);
|
||||||
|
|
||||||
// ==================== 班级教师管理 ====================
|
// ==================== 班级教师管理 ====================
|
||||||
|
|
||||||
|
|||||||
@ -162,8 +162,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'courses',
|
path: 'courses',
|
||||||
name: 'SchoolCourses',
|
name: 'SchoolCourses',
|
||||||
component: () => import('@/views/school/courses-new/CourseCenterView.vue'),
|
component: () => import('@/views/school/courses/CourseListView.vue'),
|
||||||
meta: { title: '课程中心' },
|
meta: { title: '课程管理' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'courses/:id',
|
path: 'courses/:id',
|
||||||
@ -276,7 +276,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'courses',
|
path: 'courses',
|
||||||
name: 'TeacherCourses',
|
name: 'TeacherCourses',
|
||||||
component: () => import('@/views/teacher/courses-new/CourseCenterView.vue'),
|
component: () => import('@/views/teacher/courses/CourseListView.vue'),
|
||||||
meta: { title: '课程中心' },
|
meta: { title: '课程中心' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -59,7 +59,7 @@
|
|||||||
<template #title>教学管理</template>
|
<template #title>教学管理</template>
|
||||||
<a-menu-item key="courses">
|
<a-menu-item key="courses">
|
||||||
<template #icon><ReadOutlined /></template>
|
<template #icon><ReadOutlined /></template>
|
||||||
<span>课程中心</span>
|
<span>课程管理</span>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="school-courses">
|
<a-menu-item key="school-courses">
|
||||||
<template #icon><FolderAddOutlined /></template>
|
<template #icon><FolderAddOutlined /></template>
|
||||||
|
|||||||
@ -1,599 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,251 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,580 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,259 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.reading.platform.common.response.PageResult;
|
import com.reading.platform.common.response.PageResult;
|
||||||
import com.reading.platform.common.response.Result;
|
import com.reading.platform.common.response.Result;
|
||||||
import com.reading.platform.common.security.SecurityUtils;
|
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.LessonTagResponse;
|
||||||
import com.reading.platform.dto.response.SchoolCourseResponse;
|
import com.reading.platform.dto.response.SchoolCourseResponse;
|
||||||
import com.reading.platform.entity.CourseLesson;
|
import com.reading.platform.entity.CourseLesson;
|
||||||
@ -94,14 +93,12 @@ public class SchoolCourseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@Operation(summary = "获取课程详情(包含课程环节、介绍、资源等完整信息)")
|
@Operation(summary = "获取课程详情")
|
||||||
public Result<CourseResponse> getSchoolCourse(@PathVariable Long id) {
|
public Result<SchoolCourseResponse> getSchoolCourse(@PathVariable Long id) {
|
||||||
log.info("获取课程详情,id={}", id);
|
log.info("获取课程详情,id={}", id);
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
// 验证权限
|
CoursePackage course = courseService.getCourseByIdWithTenantCheck(id, tenantId);
|
||||||
courseService.getCourseByIdWithTenantCheck(id, tenantId);
|
return Result.success(SchoolCourseResponse.toSchoolCourseResponse(course));
|
||||||
// 返回完整详情(与教师端一致)
|
|
||||||
return Result.success(courseService.getCourseByIdWithLessons(id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import com.reading.platform.dto.request.RenewRequest;
|
|||||||
import com.reading.platform.dto.response.CourseCollectionResponse;
|
import com.reading.platform.dto.response.CourseCollectionResponse;
|
||||||
import com.reading.platform.dto.response.CourseResponse;
|
import com.reading.platform.dto.response.CourseResponse;
|
||||||
import com.reading.platform.dto.response.CoursePackageResponse;
|
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.PackageInfoResponse;
|
||||||
import com.reading.platform.dto.response.PackageUsageResponse;
|
import com.reading.platform.dto.response.PackageUsageResponse;
|
||||||
import com.reading.platform.entity.Tenant;
|
import com.reading.platform.entity.Tenant;
|
||||||
@ -47,19 +46,8 @@ public class SchoolPackageController {
|
|||||||
@GetMapping("/{collectionId}/packages")
|
@GetMapping("/{collectionId}/packages")
|
||||||
@Operation(summary = "获取课程套餐下的课程包列表")
|
@Operation(summary = "获取课程套餐下的课程包列表")
|
||||||
@RequireRole({UserRole.SCHOOL, UserRole.TEACHER})
|
@RequireRole({UserRole.SCHOOL, UserRole.TEACHER})
|
||||||
public Result<List<CoursePackageResponse>> getPackagesByCollection(
|
public Result<List<CoursePackageResponse>> getPackagesByCollection(@PathVariable Long collectionId) {
|
||||||
@PathVariable Long collectionId,
|
return Result.success(collectionService.getPackagesByCollection(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")
|
@PostMapping("/{collectionId}/renew")
|
||||||
|
|||||||
@ -81,33 +81,6 @@ public class CoursePackageResponse {
|
|||||||
@Schema(description = "排序号(在课程套餐中的顺序)")
|
@Schema(description = "排序号(在课程套餐中的顺序)")
|
||||||
private Integer sortOrder;
|
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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课程包中的课程项
|
* 课程包中的课程项
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
import com.reading.platform.dto.response.CourseCollectionResponse;
|
import com.reading.platform.dto.response.CourseCollectionResponse;
|
||||||
import com.reading.platform.dto.response.CoursePackageResponse;
|
import com.reading.platform.dto.response.CoursePackageResponse;
|
||||||
import com.reading.platform.dto.response.PackageFilterMetaResponse;
|
|
||||||
import com.reading.platform.entity.CourseCollection;
|
import com.reading.platform.entity.CourseCollection;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@ -35,23 +34,6 @@ public interface CourseCollectionService extends IService<CourseCollection> {
|
|||||||
*/
|
*/
|
||||||
List<CoursePackageResponse> getPackagesByCollection(Long collectionId);
|
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);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建课程套餐
|
* 创建课程套餐
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import com.reading.platform.common.exception.BusinessException;
|
|||||||
import com.reading.platform.common.response.PageResult;
|
import com.reading.platform.common.response.PageResult;
|
||||||
import com.reading.platform.dto.response.CourseCollectionResponse;
|
import com.reading.platform.dto.response.CourseCollectionResponse;
|
||||||
import com.reading.platform.dto.response.CoursePackageResponse;
|
import com.reading.platform.dto.response.CoursePackageResponse;
|
||||||
import com.reading.platform.dto.response.PackageFilterMetaResponse;
|
|
||||||
import com.reading.platform.entity.*;
|
import com.reading.platform.entity.*;
|
||||||
import com.reading.platform.mapper.*;
|
import com.reading.platform.mapper.*;
|
||||||
import com.reading.platform.service.CourseCollectionService;
|
import com.reading.platform.service.CourseCollectionService;
|
||||||
@ -23,11 +22,7 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,8 +38,8 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
|||||||
private final CoursePackageMapper packageMapper;
|
private final CoursePackageMapper packageMapper;
|
||||||
private final TenantPackageMapper tenantPackageMapper;
|
private final TenantPackageMapper tenantPackageMapper;
|
||||||
private final CoursePackageCourseMapper packageCoursePackageMapper;
|
private final CoursePackageCourseMapper packageCoursePackageMapper;
|
||||||
|
private final CoursePackageMapper courseMapper;
|
||||||
private final CourseLessonService courseLessonService;
|
private final CourseLessonService courseLessonService;
|
||||||
private final ThemeMapper themeMapper;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询租户的课程套餐列表
|
* 查询租户的课程套餐列表
|
||||||
@ -204,186 +199,6 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
|||||||
return result;
|
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建课程套餐
|
* 创建课程套餐
|
||||||
*/
|
*/
|
||||||
@ -860,17 +675,8 @@ public class CourseCollectionServiceImpl extends ServiceImpl<CourseCollectionMap
|
|||||||
.discountPrice(null)
|
.discountPrice(null)
|
||||||
.discountType(null)
|
.discountType(null)
|
||||||
.gradeLevels(gradeLevelsArray)
|
.gradeLevels(gradeLevelsArray)
|
||||||
.gradeTags(gradeLevelsArray) // 同时设置 gradeTags 字段,供前端使用
|
|
||||||
.courseCount(lessons.size()) // 使用课程环节数量
|
.courseCount(lessons.size()) // 使用课程环节数量
|
||||||
.status(pkg.getStatus())
|
.status(pkg.getStatus())
|
||||||
// 新增字段:封面、绘本、主题
|
|
||||||
.coverImagePath(pkg.getCoverImagePath())
|
|
||||||
.pictureBookName(pkg.getPictureBookName())
|
|
||||||
.themeId(pkg.getThemeId())
|
|
||||||
.durationMinutes(pkg.getDurationMinutes())
|
|
||||||
.usageCount(pkg.getUsageCount())
|
|
||||||
.avgRating(pkg.getAvgRating())
|
|
||||||
// 审核相关
|
|
||||||
.submittedAt(pkg.getSubmittedAt())
|
.submittedAt(pkg.getSubmittedAt())
|
||||||
.submittedBy(pkg.getSubmittedBy())
|
.submittedBy(pkg.getSubmittedBy())
|
||||||
.reviewedAt(pkg.getReviewedAt())
|
.reviewedAt(pkg.getReviewedAt())
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user