不管是在线教育平台、企业内部培训系统,还是学校的信息化教务系统,“教学课程”往往是最核心也最容易越做越乱的一部分。很多项目初期只记录课程名称和讲师,随着课程数量增加,又要补章、补目录、补关联资料,最后表结构越改越多,前端也面临各种字段对不上。本文从一个可落地的最小闭环出发,完整演示“教学课程”模块从需求分析、表结构设计到后端接口、前端页面沉淀下来的一套做法,后端采用主流的 Spring Boot,前端用直观的 HTML + JavaScript,减少额外工程依赖,让零基础读者也能照着跑起来。
这套模块的定位是“课程内容管理”:课程创建、课程发布/下线、课程章节维护、课程列表查询。它不追求所有 CMS 能力一把梭,而是先把骨架搭稳。只要理解了这一层,后续再扩展讲师、分类、选课、学习进度等功能都会顺畅很多。
1. 教学课程模块要解决的业务问题
1.1 什么是一份“教学课程”数据
在开发人员眼中,“教学课程”并不只是页面上一张课程卡片,而是一组有层级的结构化数据。最外层是课程,课程下面有章节,章节下面可以有课时或知识点。比如《Java 零基础入门》课程,课时 01 是“环境安装”,课时 02 是“变量与数据类型”,这些内容按顺序组织起来,才形成完整课程。
从数据模型看,课程具有几个特性:
- 课程有标题、简介、封面等基础属性。
- 课程包含多个章节,章节有自己的标题、排序。
- 课程和章节都不是一次性创建完就固定,需要支持编辑和上下架。
- 课程可能归属某个分类,例如 Java、Python、数据库、前端。
如果系统里没有单独的教学课程模块,这些信息很容易散落在 Excel、Word 或不同的系统模块里。课程一旦多了,查找、更新、统计都会变得非常低效。
1.2 典型应用场景
教学课程模块常见的落地场景包括以下几种。
第一是公开课平台,运营人员在后台创建课程、配置章节,学员在小程序或 Web 端观看。第二是企业内部培训系统,HR 或培训管理员把线下课件整理成电子课程,按部门发布。第三是学校教务系统,课程信息需要与教师、班级、学期关联。
在这些场景中,无论面向 C 端还是 B 端,核心能力都比较一致。本文会围绕一个实际演示需求来展开:“让管理员可以在后台创建一门教学课程,查看课程列表,继续往课程里添加章节,并对课程进行发布或下线。”这就形成了一个最小但完整的功能闭环。
2. 需求梳理与功能范围
2.1 先画出核心流程
为了避免前期过度设计,建议先把角色和动作理清楚。设想一个后台管理员,通过网页登录后可以维护教学课程。不考虑权限、订单、学习记录等复杂业务,功能主要包括:
- 新建课程。
- 查询课程列表。
- 查看课程详情。
- 编辑课程基础信息。
- 删除或下线课程。
- 在课程下新增章节。
- 查看章节列表。
课程状态可以设计为“草稿、上线、下线”三种。新建课程时默认是草稿状态,管理员确认内容无误后,再手动发布。已发布的课程由于某些原因需要调整时,可以下线后再编辑。
这样流程可以简单描述如下:
| 操作阶段 | 动作 | 说明 |
|---|---|---|
| 准备阶段 | 创建课程 | 录入标题、简介、分类、封面 |
| 完善阶段 | 添加章节 | 课程下按顺序维护章信息 |
| 发布阶段 | 发布课程 | 状态从草稿改为上线 |
| 维护阶段 | 下线/编辑 | 更新内容后再次发布 |
| 清理阶段 | 删除 | 仅允许删除草稿或已下线课程 |
2.2 更关心表结构而不是页面数量
很多刚入门的同学会把主要精力放在按钮和弹窗上,但教学课程这类管理模块,最容易出问题的往往不是页面样式,而是表和表之间的关系。
课程表和章节表是一对多关系。课程表存储课程基本信息和当前状态;章节表存储课程 ID、章节标题和排序号。两张表通过课程 ID 关联。
在生成代码之前,先明确几个设计要点。
不要把章、节、课时全部塞进一张表。虽然一张表也能实现,但一旦出现层级调整、排序、移动章节,代码复杂度会明显上升。一般教学中“课程 - 章”的关系已经够用,如果还要挂视频、文档、考试,通常再增加一个“课次”或“学习资源”表。
课程删除不建议直接物理删除。如果学员已经产生选课记录,物理删除会把历史记录一起破坏。建议引入状态字段,平时使用“下线”代替删除。管理员如果确实需要清理脏数据,才走物理删除。
创建时间和更新时间虽然看起来不起眼,却对后续维护非常关键。做数据统计、内容审核、问题回溯时都依赖这两个字段。
2.3 模块边界别乱扩
教学课程模块的边界建议先收敛在“内容维护”上,不要一开始就把选课、支付、评论、统计全部加进来。尤其对于练习项目,多一个边界就多一套权限和事务逻辑。等课程模块稳定后,再拆出独立的订单模块、学习记录模块,比如用户选课时才去读取课程状态,而不是在课程表里增加选课人数实时字段。
这里采用“课程主数据 + 章节清单”的思路,后续如果要做小程序端,可以直接复用后端接口;要做后台运营,也只需要根据角色把接口单独包一层。
3. 环境准备与数据库设计
3.1 技术版本说明
本文以 Spring Boot 2.x + MySQL 8.x + JDK 8/11 为常见演示环境,后续如需升级到 Spring Boot 3.x,注意把实体上引入的持久化注解包由javax.persistence.*改为jakarta.persistence.*,数据源依赖名也需要按 Spring Boot 官方迁移说明调整。如果读者项目采用其他版本,不必强求完全一致,关键是理解字段与接口的组织方式。
演示项目建议使用 Maven 管理依赖。需要准备的组件包括:
- JDK 8 或 JDK 11。
- Maven 3.6 以上。
- MySQL 5.7 或 8.0。
- IntelliJ IDEA 或其他常用 IDE。
如果本机没有配置过 JPA 项目,也可以直接创建 Spring Initializr 项目,然后勾选 Web、JPA、MySQL Driver、Lombok 依赖。需要注意一点:Lombok 需要 IDE 安装并启用对应插件才能编译通过。
3.2 建库与建表
这里先创建一个数据库:teaching_course_db。
CREATE DATABASE IF NOT EXISTS teaching_course_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;选择utf8mb4而不是utf8,是为了保证课程标题、简介等文本里出现 emoji 或生僻字时不会编码报错。
接下来创建课程表t_course。
USE teaching_course_db; CREATE TABLE t_course ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', course_name VARCHAR(100) NOT NULL COMMENT '课程标题', category VARCHAR(50) DEFAULT NULL COMMENT '课程分类,如Java/Python', summary VARCHAR(500) DEFAULT NULL COMMENT '课程简介', cover_url VARCHAR(255) DEFAULT NULL COMMENT '课程封面地址', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0草稿 1已发布 2已下线', create_user_id BIGINT DEFAULT NULL COMMENT '创建人ID,预留字段', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), KEY idx_course_category (category), KEY idx_course_status (status) ) ENGINE=InnoDB COMMENT='教学课程表';创建章节表t_chapter。
CREATE TABLE t_chapter ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', course_id BIGINT NOT NULL COMMENT '所属课程ID', chapter_title VARCHAR(200) NOT NULL COMMENT '章节标题', chapter_order INT NOT NULL DEFAULT 0 COMMENT '章节排序,数字越小越靠前', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), KEY idx_chapter_course (course_id) ) ENGINE=InnoDB COMMENT='课程章节表';所有涉及业务数据字典的字段建议用代码注释写清楚含义。状态字段使用整数而不是字符串,虽然直觉上0/1/2不如DRAFT/PUBLISHED直观,但在数据库中整数占用空间更小、索引效率更高。业务含义由后端枚举或常量统一解释。
关于外键,这里不创建物理外键。原因有两个:一是当前模块只维护课程和章节,删除课程时前端可能还要告诉管理员“该课程下还有章节,不能直接删除”,需要由应用层判断;二是物理外键在数据量大后会影响写入和锁竞争。关键数据一致性通过业务代码控制。如果是学校等对一致性要求较高的内部系统,也可以保留物理外键,但这会让后期分表、归档操作更麻烦。
3.3 整理实体关系
课程表和章节表的关系如下:
- 一条课程记录对应多条章节记录。
- 章节记录通过
course_id指向课程。 - 查询章节列表时按照
chapter_order升序返回。
表结构设计好后,项目目录可以规划如下。
demo-teaching-course ├── pom.xml └── src/main ├── java/com/example/teachingcourse │ ├── TeachingCourseApplication.java │ ├── entity │ │ ├── Course.java │ │ └── CourseChapter.java │ ├── repository │ │ ├── CourseRepository.java │ │ └── CourseChapterRepository.java │ ├── service │ │ ├── CourseService.java │ │ └── CourseChapterService.java │ └── controller │ ├── CourseController.java │ └── CourseChapterController.java └── resources ├── application.yml └── static └── index.html这种按“实体、仓库、服务、控制器”分层的写法很常见,初学者可能感觉类变多了,但它的好处是每一层职责清晰:实体类描述数据库结构,Repository 负责查询,Service 负责核心业务规则,Controller 只负责接收请求和返回结果。
4. Spring Boot 后端接口实现
4.1 Maven 依赖
项目使用 Maven,具体依赖如下。以 Spring Boot 2.7 为例,父工程版本需要在pom.xml中指定。如果读者项目中已经存在 parent 配置,则可以省略该项目级 parent。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>如果你的项目不是空项目,且已经使用其他数据库,请以实际依赖和数据库连接配置为准,不要直接照搬这一段而不改连接信息。
4.2 配置文件
在src/main/resources/application.yml中填写数据源配置。
server: port: 8080 spring: application: name: teaching-course datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/teaching_course_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 jpa: hibernate: ddl-auto: validate show-sql: true open-in-view: false连接参数中重点说明一下:
characterEncoding=utf8保证中文正常读写。serverTimezone=Asia/Shanghai避免 MySQL 与 JVM 时区不一致导致时间错乱。useSSL=false用于本地开发。allowPublicKeyRetrieval=true解决 MySQL 8 使用 caching_sha2_password 认证时可能出现的连接失败问题。
ddl-auto设为validate,意思是每次启动只校验实体和表结构是否一致,不会自动修改表结构,这比直接使用update更安全。如果是第一次学习,尚未手工执行建表 SQL,也可以临时设置update让 Hibernate 自动建表,但生产环境不要这样用。
open-in-view: false是 Spring Boot 2.x 之后官方建议关闭的配置,避免事务外仍持有数据库连接。
4.3 课程实体类
先编写课程实体类Course.java。这段代码使用 Lombok 的@Data生成 getter/setter,如果你不喜欢 Lombok,可以让 IDE 自动生成,或者写成传统 JavaBean。
package com.example.teachingcourse.entity; import lombok.Data; import javax.persistence.*; import java.time.LocalDateTime; @Data @Entity @Table(name = "t_course") public class Course { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "course_name", nullable = false, length = 100) private String courseName; @Column(name = "category", length = 50) private String category; @Column(name = "summary", length = 500) private String summary; @Column(name = "cover_url", length = 255) private String coverUrl; @Column(name = "status", nullable = false) private Integer status; @Column(name = "create_user_id") private Long createUserId; @Column(name = "create_time", updatable = false) private LocalDateTime createTime; @Column(name = "update_time") private LocalDateTime updateTime; @PrePersist public void prePersist() { if (status == null) { status = 0; } LocalDateTime now = LocalDateTime.now(); if (createTime == null) { createTime = now; } if (updateTime == null) { updateTime = now; } } @PreUpdate public void preUpdate() { updateTime = LocalDateTime.now(); } }这里有两个细节值得注意。
第一,@PrePersist会在插入前回调。Common 的误区是创建了对象,但没有调用 setter 设置时间和状态,即使数据库列有默认值,当 JPA 插入时也可能把 null 写进去,因此最好在代码层面兜底。
第二,@PreUpdate负责在更新前刷新updateTime。如果没有这个方法,只靠数据库的ON UPDATE CURRENT_TIMESTAMP,在 JPA 调用 update 时也可能出现时间不变或代码值覆盖数据库默认值的情况。
4.4 章节实体类
接下来编写CourseChapter.java,对应t_chapter表。
package com.example.teachingcourse.entity; import lombok.Data; import javax.persistence.*; import java.time.LocalDateTime; @Data @Entity @Table(name = "t_chapter") public class CourseChapter { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "course_id", nullable = false) private Long courseId; @Column(name = "chapter_title", nullable = false, length = 200) private String chapterTitle; @Column(name = "chapter_order", nullable = false) private Integer chapterOrder; @Column(name = "create_time", updatable = false) private LocalDateTime createTime; @Column(name = "update_time") private LocalDateTime updateTime; @PrePersist public void prePersist() { if (chapterOrder == null) { chapterOrder = 0; } LocalDateTime now = LocalDateTime.now(); if (createTime == null) { createTime = now; } if (updateTime == null) { updateTime = now; } } @PreUpdate public void preUpdate() { updateTime = LocalDateTime.now(); } }章节属于某门课程,因此这里不另外保存课程名称,查询章节列表时如果需要课程名称,可以由前端传入或者通过 join 查询组装。对于简单场景,只保存课程 ID 已经足够。
4.5 Repository 接口
Repository 层使用 Spring Data JPA。CourseRepository需要提供简单的 CRUD、按状态筛选、按关键字搜索能力。
package com.example.teachingcourse.repository; import com.example.teachingcourse.entity.Course; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface CourseRepository extends JpaRepository<Course, Long>, JpaSpecificationExecutor<Course> { }章节仓库的接口如下:
package com.example.teachingcourse.repository; import com.example.teachingcourse.entity.CourseChapter; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface CourseChapterRepository extends JpaRepository<CourseChapter, Long> { List<CourseChapter> findByCourseIdOrderByChapterOrderAsc(Long courseId); long countByCourseId(Long courseId); }findByCourseIdOrderByChapterOrderAsc是 Spring Data JPA 的方法命名查询,框架会自动解析方法名,按courseId过滤,并按chapterOrder升序返回。
4.6 课程业务 Service
课程模块不能直接返回全部实体给前端,但练习项目在数据量不大时,首先保证逻辑简洁。可以单独写一个CourseDTO,也可以先复用实体。这里为了降低篇幅,代码先直接返回实体,真实项目中更推荐使用 DTO 隔离实体。
package com.example.teachingcourse.service; import com.example.teachingcourse.entity.Course; import com.example.teachingcourse.entity.CourseChapter; import com.example.teachingcourse.repository.CourseChapterRepository; import com.example.teachingcourse.repository.CourseRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.List; import java.util.Optional; @Service public class CourseService { private final CourseRepository courseRepository; private final CourseChapterRepository courseChapterRepository; public CourseService(CourseRepository courseRepository, CourseChapterRepository courseChapterRepository) { this.courseRepository = courseRepository; this.courseChapterRepository = courseChapterRepository; } @Transactional(readOnly = true) public List<Course> listCourses(String keyword, Integer status) { if (StringUtils.hasText(keyword)) { return courseRepository.findAll((root, query, cb) -> { query.orderBy(cb.desc(root.get("createTime"))); var predicate = cb.conjunction(); if (StringUtils.hasText(keyword)) { predicate = cb.and(predicate, cb.like(root.get("courseName"), "%" + keyword.trim() + "%")); } if (status != null) { predicate = cb.and(predicate, cb.equal(root.get("status"), status)); } return predicate; }); } if (status != null) { return courseRepository.findAll((root, query, cb) -> cb.equal(root.get("status"), status)); } return courseRepository.findAll((root, query, cb) -> query.orderBy(cb.desc(root.get("createTime"))).getRestriction()); } @Transactional(readOnly = true) public Course getCourseDetail(Long id) { return courseRepository.findById(id).orElse(null); } @Transactional public Course createCourse(Course course) { if (course.getStatus() == null) { course.setStatus(0); } return courseRepository.save(course); } @Transactional public Course updateCourse(Long id, Course input) { Optional<Course> optional = courseRepository.findById(id); if (optional.isEmpty()) { throw new IllegalArgumentException("课程不存在"); } Course course = optional.get(); course.setCourseName(input.getCourseName()); course.setCategory(input.getCategory()); course.setSummary(input.getSummary()); course.setCoverUrl(input.getCoverUrl()); course.setStatus(input.getStatus()); return courseRepository.save(course); } @Transactional public boolean deleteCourse(Long id) { long chapterCount = courseChapterRepository.countByCourseId(id); if (chapterCount > 0) { throw new IllegalStateException("请先删除该课程下的章节"); } courseRepository.deleteById(id); return true; } }编辑课程时为什么不像新建一样直接save?核心原因是直接保存会覆盖不想修改的字段。如果用前端传回一个状态值为 null 的对象,直接调用save,就可能把已有记录更新为 null。正确的做法是先查询数据库原始对象,再把允许修改的字段逐个复制上去,最后保存。
删除课程前先检查章节数量,是为了避免留下孤儿数据。最安全的做法是逻辑下线,而不是真正删除。
4.7 章节业务 Service
章节 Service 提供新增、查询、按课程批量查询等方法。
package com.example.teachingcourse.service; import com.example.teachingcourse.entity.CourseChapter; import com.example.teachingcourse.repository.CourseChapterRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service public class CourseChapterService { private final CourseChapterRepository courseChapterRepository; public CourseChapterService(CourseChapterRepository courseChapterRepository) { this.courseChapterRepository = courseChapterRepository; } @Transactional(readOnly = true) public List<CourseChapter> listChaptersByCourseId(Long courseId) { return courseChapterRepository.findByCourseIdOrderByChapterOrderAsc(courseId); } @Transactional public CourseChapter addChapter(CourseChapter chapter) { if (chapter.getChapterOrder() == null) { chapter.setChapterOrder(0); } return courseChapterRepository.save(chapter); } @Transactional public void deleteChapter(Long id) { courseChapterRepository.deleteById(id); } }4.8 Controller 接口层
Controller 层负责把 HTTP 请求转换为 Service 调用。
package com.example.teachingcourse.controller; import com.example.teachingcourse.entity.Course; import com.example.teachingcourse.service.CourseService; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/courses") public class CourseController { private final CourseService courseService; public CourseController(CourseService courseService) { this.courseService = courseService; } @GetMapping public List<Course> list(@RequestParam(required = false) String keyword, @RequestParam(required = false) Integer status) { return courseService.listCourses(keyword, status); } @GetMapping("/{id}") public Course detail(@PathVariable Long id) { return courseService.getCourseDetail(id); } @PostMapping public Course create(@RequestBody Course course) { return courseService.createCourse(course); } @PutMapping("/{id}") public Course update(@PathVariable Long id, @RequestBody Course course) { return courseService.updateCourse(id, course); } @DeleteMapping("/{id}") public String delete(@PathVariable Long id) { courseService.deleteCourse(id); return "success"; } }章节接口可以单独建一个 Controller,也可以放在同一个控制器里。为了目录清晰,这里新建CourseChapterController。
package com.example.teachingcourse.controller; import com.example.teachingcourse.entity.CourseChapter; import com.example.teachingcourse.service.CourseChapterService; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/courses/{courseId}/chapters") public class CourseChapterController { private final CourseChapterService courseChapterService; public CourseChapterController(CourseChapterService courseChapterService) { this.courseChapterService = courseChapterService; } @GetMapping public List<CourseChapter> list(@PathVariable Long courseId) { return courseChapterService.listChaptersByCourseId(courseId); } @PostMapping public CourseChapter add(@PathVariable Long courseId, @RequestBody CourseChapter chapter) { chapter.setCourseId(courseId); return courseChapterService.addChapter(chapter); } @DeleteMapping("/{id}") public String delete(@PathVariable Long courseId, @PathVariable Long id) { courseChapterService.deleteChapter(id); return "success"; } }到这里,后端已经具备一个基础能力:课程新增、列表、详情、编辑、删除,章新增和删除。接下来用一个简单的前端页面,把这些接口串起来。
5. 前端页面设计与演示
项目启动后,Spring Boot 默认会加载src/main/resources/static下的静态文件。在static目录中创建index.html,页面就能通过http://localhost:8080/index.html访问。
这个页面使用原生 JavaScript,不依赖前端构建工具。它主要完成三件事:加载课程列表、创建课程、查看选中课程的章节。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>教学课程管理 - 演示页</title> <style> body { font-family: "Microsoft YaHei", Arial, sans-serif; background: #f5f7fa; margin: 0; padding: 20px; } .container { max-width: 960px; margin: 0 auto; background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } .form-row { margin-bottom: 12px; display: flex; gap: 10px; } .form-row input, .form-row button { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; } button { cursor: pointer; background: #2f6fed; color: #fff; border: none; } .course-item { padding: 12px; border: 1px solid #e6e8eb; border-radius: 6px; margin-bottom: 12px; } .status-label { font-size: 12px; padding: 2px 8px; border-radius: 4px; } .chapter-list { margin-top: 10px; padding: 10px; background: #f9fafc; border-radius: 4px; } </style> </head> <body> <div class="container"> <h2>教学课程管理演示</h2> <div class="form-row"> <input type="text" id="courseName" placeholder="请输入课程标题"> <input type="text" id="category" placeholder="分类,如 Java"> <button onclick="createCourse()">创建课程</button> </div> <div id="courseList"></div> </div> <script> const BASE_URL = 'http://localhost:8080'; // 获取全部课程并渲染 async function loadCourses() { const response = await fetch(`${BASE_URL}/api/courses`); if (!response.ok) { alert('加载课程失败'); return; } const courses = await response.json(); const container = document.getElementById('courseList'); if (courses.length === 0) { container.innerHTML = '<p>暂无课程,请先创建。</p>'; return; } let html = ''; courses.forEach(course => { const statusText = course.status === 1 ? '已发布' : course.status === 2 ? '已下线' : '草稿'; html += ` <div class="course-item"> <h3>${escapeHtml(course.courseName)} <span class="status-label">${statusText}</span></h3> <p>分类:${escapeHtml(course.category || '未分类')}</p> <p>简介:${escapeHtml(course.summary || '')}</p> <button onclick="loadChapters(${course.id})">查看章节</button> <button onclick="publishCourse(${course.id})">发布</button> <div class="chapter-list" id="chapterBox_${course.id}"></div> </div> `; }); container.innerHTML = html; } // 创建课程 async function createCourse() { const courseName = document.getElementById('courseName').value.trim(); const category = document.getElementById('category').value.trim(); if (!courseName) { alert('课程标题不能为空'); return; } const course = { courseName: courseName, category: category, summary: '这是一门示例教学课程', status: 0 }; const response = await fetch(`${BASE_URL}/api/courses`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(course) }); if (response.ok) { document.getElementById('courseName').value = ''; document.getElementById('category').value = ''; loadCourses(); } else { alert('新增失败'); } } // 加载指定课程下的章节 async function loadChapters(courseId) { const box = document.getElementById(`chapterBox_${courseId}`); const response = await fetch(`${BASE_URL}/api/courses/${courseId}/chapters`); const chapters = await response.json(); let html = '<p><b>章节列表:</b></p>'; if (chapters.length === 0) { html += '<p>还没有章节。</p>'; } else { html += '<ul>'; chapters.forEach((chapter, index) => { html += `<li>${index + 1}. ${escapeHtml(chapter.chapterTitle)}</li>`; }); html += '</ul>'; } html += '<input type="text" id="newChapter_' + courseId + '" placeholder="请输入章节标题">'; html += `<button onclick="addChapter(${courseId})">添加章节</button>`; box.innerHTML = html; } // 添加章节 async function addChapter(c