Преглед на файлове

Phase1: 模块字段/接口三端对齐 + 模块管理页 + 小程序模块列表(wealth/health/growth)

liaoxg преди 1 седмица
родител
ревизия
72bbd7d598

+ 459 - 0
README.md

@@ -0,0 +1,459 @@
+# 爱伴·AI之旅 训练营系统
+
+**CareMate · AI Journey — Training Camp System**
+
+> 一套面向AI技能培训的全端训练营系统,支持L0-L4五级课程体系、学员成长管理、现场投屏互动、团报裂变与完课证书发放。
+
+---
+
+## 目录
+
+1. [项目概述](#项目概述)
+2. [项目结构](#项目结构)
+3. [技术栈](#技术栈)
+4. [系统架构](#系统架构)
+5. [快速开始](#快速开始)
+6. [功能模块](#功能模块)
+7. [数据库设计](#数据库设计)
+8. [前端页面](#前端页面)
+9. [后端接口](#后端接口)
+10. [管理后台路由](#管理后台路由)
+11. [开发规范](#开发规范)
+12. [编码硬约束](#编码硬约束)
+13. [外部依赖与阻塞项](#外部依赖与阻塞项)
+14. [Git工作流](#git工作流)
+15. [产品愿景](#产品愿景)
+
+---
+
+## 项目概述
+
+「爱伴·AI之旅」(CareMate · AI Journey)是一套完整的AI技能培训训练营系统,覆盖学员从报名、学习、互动到完课的全生命周期管理。
+
+**核心能力:**
+- 五级课程体系(L0-L4),支持模块化半天制教学
+- 班次管理与插班制,支持灵活排课
+- 现场投屏大屏互动,提升课堂参与感
+- 三本账分工、互教教学卡、两轮投票等教学互动机制
+- 完课证书(CM编号体系)与防伪查验
+- 32枚徽章 + 5段位成长体系
+- 沙龙引流 → L0高频 → L1模块化 → L2系统 → L3陪跑 → L4讲师的完整漏斗
+
+---
+
+## 项目结构
+
+```
+train/                          # 单 git 仓库
+├── train-backend/              # Spring Boot 2.7.18 / Java 8 / MyBatis-Plus 3.5.3.1
+├── train-frontend/             # uni-app Vue2 小程序(学员端)
+├── train-web/                  # Vue2 + Element UI 管理后台
+├── docs/                       # 二次文档(SRS/详细设计/需求变更/合规核查)
+└── AGENTS.md                   # AI Agent 工作规范
+```
+
+---
+
+## 技术栈
+
+| 端 | 技术栈 | 端口 |
+|---|---|---|
+| train-backend | Spring Boot 2.7.18 / Java 8 / MyBatis-Plus 3.5.3.1 / JWT | 9083 |
+| train-frontend | uni-app Vue2 (@dcloudio 2.x) / HBuilderX 打包微信小程序 | — |
+| train-web | Vue2 + vue-cli 5 + Element UI | — |
+| 数据库 | MySQL 192.168.16.251:3306 / zxyj (cfc 同库) | 3306 |
+
+---
+
+## 系统架构
+
+```
++-------------------+     +-------------------+     +-------------------+
+|   train-frontend  |     |     train-web     |     |   train-backend   |
+|   (微信小程序)    |     |   (管理后台)      |     |   (Spring Boot)   |
+|   uni-app Vue2    |     |   Vue2 + Element  |     |   Java 8 / JWT    |
++--------+----------+     +--------+----------+     +--------+----------+
+         |                        |                        |
+         |     REST API (POST)    |     REST API (POST)   |
+         +------------------------+------------------------+
+                                  |
+                         +--------v--------+
+                         |   MySQL 3306    |
+                         |   zxyj 数据库   |
+                         | (cfc 同库)      |
+                         +-----------------+
+```
+
+**架构说明:**
+- 三端共享同一数据库,后端与 cfc 系统共用 `zxyj` 库
+- 所有接口统一使用 `@PostMapping`,响应格式统一为 `Result<T>`
+- JWT 密钥与 cfc 系统对齐,实现 token 互通
+
+---
+
+## 快速开始
+
+### 环境要求
+
+- JDK 8+
+- Node.js 14+
+- Maven 3.6+
+- HBuilderX(小程序打包)
+- 微信开发者工具
+
+### 后端启动
+
+```bash
+cd train-backend
+mvn clean compile          # 编译通过即提交(skipTests=true)
+mvn spring-boot:run        # 启动服务,端口 9083
+```
+
+**数据库连接:**
+- 地址:通过环境变量 `TRAIN_DB_URL` 配置(默认 `192.168.16.251:3306/zxyj`)
+- 账号密码:通过 `TRAIN_DB_USERNAME` / `TRAIN_DB_PASSWORD` 环境变量配置
+- Schema 由 `DatabaseInitializer` 启动时自动执行 `schema.sql`
+
+### 前端(学员端 H5)
+
+```bash
+cd train-frontend
+npm install
+npm run dev:h5             # 开发模式
+npm run build:h5           # 构建生产包
+```
+
+### 小程序打包
+
+1. HBuilderX 导入 `train-frontend/` 目录
+2. 运行 → 运行到微信开发者工具
+3. 产物位于 `unpackage/dist/dev/mp-weixin/`
+
+> ⚠️ 注意:`npm run build:mp-weixin` 的 dist 产物不完整,**必须使用 HBuilderX 打包**
+
+### 管理后台
+
+```bash
+cd train-web
+npm install
+npm run dev                # 开发模式
+npm run build              # 构建生产包
+```
+
+---
+
+## 功能模块
+
+### 已落地模块
+
+| 阶段 | 提交范围 | 内容 |
+|---|---|---|
+| Phase1-8 | ce8db0b~6a08ebd | 基础框架(JWT/Schema/CURD/投屏大屏/后台角色路由) |
+| Phase9-10 | 47301fb~9ba5889 | 课程现场投屏大屏 |
+| Phase11 | 56a64fd | 班级码进班联动 enrollment |
+| Phase12 | 372b4ec | 分享海报 sharePath/shareText |
+| Phase13 | d811135 | FollowUpTask T+21/T+30 + PreClassReminderTask |
+| Phase14 | afb30a1 | 团报发票与案例授权 |
+| Phase15a-b | f1bdbe2~248714d | 课程双态(列表/主页)+ 管理端课程审核 |
+| 登录统一 | c85d2ba~c9fa4cc | 浮层登录 + 全局守卫 + storage 兜底 |
+| 报名重构 | 4605b2c~9f06216 | 报名挂课程 + 后台分配班次 + 按课程分组 |
+| 合规修复 | b9d8daa | 移除学员端违规入口(SRS CR-003/022) |
+| 课程增强 | 092ffb3~5371fda | 版块维护 + 资料维护 + 学员管理强化(SRS CR-013) |
+| PhaseA3 | d5ab795 | vote/finish 两个 L1 券触发点 |
+| 完善 | a2046b3 | 订阅消息真实推送 + 课前课后定时任务 + 海报小程序码 |
+| Phase4 | 1efa4cc | 沙龙产品化与浠艾福活动打通 |
+| Phase5 | 0c81bef | 完课证书编号体系(CM 格式)+ 防伪查验 |
+| Phase6 | 5cb6b4d | 徽章与成长体系(32 枚徽章 + 5 段位 + 成长地图) |
+
+### 待实现模块
+
+| 阶段 | 内容 | 状态 |
+|---|---|---|
+| T1-T4 | 讲师平行资格体系(评教/合规笔试 2 年有效/课时费 200-1200) | 待实现 |
+
+---
+
+## 数据库设计
+
+### 训练营业务表(31 张)
+
+| 序号 | 表名 | 说明 |
+|---|---|---|
+| 1 | train_course | 课程(L0-L4 五级) |
+| 2 | train_course_section | 课程版块(管理端维护) |
+| 3 | train_class | 班次 |
+| 4 | train_user | 学员用户 |
+| 5 | train_group | 小组 |
+| 6 | train_group_member | 组内成员 |
+| 7 | train_assignment | 三本账分工 |
+| 8 | train_submission | 成果上传 |
+| 9 | train_teaching_card | 互教教学卡 |
+| 10 | train_score | 积分流水 |
+| 11 | train_vote | 两轮投票 |
+| 12 | train_roadmap | 路演材料 |
+| 13 | train_checkin | 装机打卡 |
+| 14 | train_prep_check | 数据准备自检 |
+| 15 | train_plan | 7 天行动计划 |
+| 16 | train_coupon | 卡券 |
+| 17 | train_survey | 测评兴趣调研 |
+| 18 | train_admin | 管理后台账号 |
+| 19 | train_audit_log | 审计日志 |
+| 20 | train_enrollment | 报名单 |
+| 21 | train_order | 支付订单 |
+| 22 | train_invite | 转介绍关系 |
+| 23 | train_course_material | 课前资料包 |
+| 24 | train_screen_config | 班级投屏配置 |
+| 25 | train_group_order | 团报订单 |
+| 26 | train_group_order_member | 团报成员 |
+| 27 | train_case | 学员案例 |
+| 28 | train_salon | 沙龙期次 |
+| 29 | train_certificate | 完课证书(CM 编号体系) |
+| 30 | train_badge | 徽章目录(32 枚种子) |
+| 31 | train_user_badge | 用户徽章 |
+
+### cfc 共享表(只读引用)
+
+- `users` — 用户表
+- `activities` — 活动表
+- `user_platform_balance` — 用户平台余额
+- `tianpan_member_annual_energy` — 天盘会员年度能量
+
+> 数据库与 cfc 系统共用 `zxyj` 库,`com.train.cfc` 包下的实体类映射 cfc 表结构,修改字段必须与 cfc 侧对齐。
+
+---
+
+## 前端页面
+
+### TabBar 页面(4 个)
+
+| 位置 | 页面 | 路径 |
+|---|---|---|
+| 首页 | index | pages/index/index |
+| 任务 | checkin | pages/checkin/index |
+| 课程 | course | pages/course/index |
+| 我的 | mine | pages/mine/index |
+
+### 子页面(25 个)
+
+| 页面 | 路径 | 说明 |
+|---|---|---|
+| assignment | pages/assignment/index | 三本账分工 |
+| badge | pages/badge/index | 成长地图 |
+| case | pages/case/index | 案例授权 |
+| class | pages/class/index | 进班 |
+| course/detail | pages/course/detail | 课程主页 |
+| enroll/list | pages/enroll/list | 课程报名 |
+| enroll/form | pages/enroll/form | 报名信息 |
+| group/index | pages/group/index | 我的小组 |
+| group-order/index | pages/group-order/index | 团报 |
+| group-order/create | pages/group-order/create | 发起团报 |
+| group-order/detail | pages/group-order/detail | 团报详情 |
+| login | pages/login/index | 登录页(已移除,改为首页浮层) |
+| material | pages/material/index | 课前资料 |
+| pay/index | pages/pay/index | 确认支付 |
+| pay/result | pages/pay/result | 支付结果 |
+| plan | pages/plan/index | 行动计划 |
+| roadmap | pages/roadmap/index | 路演材料 |
+| salon/list | pages/salon/list | 沙龙活动 |
+| salon/form | pages/salon/form | 沙龙报名 |
+| scoreboard | pages/scoreboard/index | 积分榜 |
+| share/index | pages/share/index | 我的海报 |
+| share/stats | pages/share/stats | 我的邀请 |
+| survey | pages/survey/index | 兴趣调研 |
+| teaching | pages/teaching/index | 教学卡 |
+| upload | pages/upload/index | 成果上传 |
+| verify | pages/verify/index | 校友身份校验 |
+| vote | pages/vote/index | 路演投票 |
+
+---
+
+## 后端接口
+
+### 学员端接口(19 个)
+
+| 控制器 | 说明 |
+|---|---|
+| AuthController | 登录认证 |
+| WxAuthController | 微信登录 |
+| BadgeController | 徽章管理 |
+| AssignmentController | 三本账分工 |
+| CaseController | 学员案例 |
+| CertVerifyController | 证书查验 |
+| ClassController | 班次管理 |
+| CourseController | 课程管理 |
+| EnrollmentController | 报名管理 |
+| GroupController | 小组管理 |
+| GroupOrderController | 团报管理 |
+| InviteController | 转介绍 |
+| MaterialController | 课前资料 |
+| OrderController | 订单支付 |
+| PlanController | 行动计划 |
+| SalonController | 沙龙管理 |
+| SubmissionController | 成果上传 |
+| UploadController | 文件上传 |
+| VoteController | 两轮投票 |
+
+### 管理端接口(12 个)
+
+| 控制器 | 说明 |
+|---|---|
+| AdminAuthController | 管理员登录 |
+| AdminBadgeController | 徽章管理 |
+| AdminBizController | 业务管理 |
+| AdminCaseController | 案例管理 |
+| AdminClassController | 班次管理 |
+| AdminCourseController | 课程管理 |
+| AdminGroupOrderController | 团报管理 |
+| AdminMaterialController | 资料管理 |
+| AdminReviewController | 审核管理 |
+| AdminSalonController | 沙龙管理 |
+| AdminScreenController | 投屏配置 |
+| AdminStatsController | 统计分析 |
+
+---
+
+## 管理后台路由
+
+| 路由 | 说明 |
+|---|---|
+| /login | 登录页 |
+| /screen | 课程现场投屏 |
+| /dashboard | 数据看板 |
+| /courses | 课程管理 |
+| /classes | 班次管理 |
+| /salons | 沙龙管理 |
+| /enrollments | 报名管理 |
+| /orders | 订单管理 |
+| /group-orders | 团报管理 |
+| /cases | 案例管理 |
+| /groups | 小组管理 |
+| /invites | 转介绍管理 |
+| /materials | 资料管理 |
+| /users | 学员管理 |
+| /review/submissions | 成果审核 |
+| /review/cards | 教学卡审核 |
+| /review/roadmaps | 路演审核 |
+| /scoreboard | 积分榜 |
+| /manual-score | 手动积分 |
+| /stats | 统计分析 |
+
+---
+
+## 开发规范
+
+### 编码硬约束
+
+#### 后端规范
+
+1. **接口统一 @PostMapping**:禁止使用 `@GetMapping`、`@PutMapping`、`@DeleteMapping`
+2. **依赖注入用 @Resource**:禁止使用 `@Autowired`,字段名 = 类名首字母小写
+3. **响应统一 Result\<T\>**:所有接口返回 `com.train.common.Result` 格式
+4. **分层架构**:Controller → Service → Mapper,禁止 Controller 直接操作 Mapper
+5. **金额字段用整数**:单位为"分",禁止使用 `double` / `float`
+
+#### 前端规范(小程序)
+
+1. **Vue2 Options API**:禁止使用 Composition API
+2. **禁止可选链**:使用 `&&` 替代 `?.`
+3. **禁止 CSS Grid**:使用 flexbox
+4. **禁止 :key 运算符**:使用方法调用 `:key="getItemKey(item)"`
+5. **禁止 :class 方法调用**:不能在 :class 绑定中调用方法
+6. **禁止 new Date(string)**:使用 `parseDate()` 解析时间
+7. **禁止 CSS 中文类名**:类名/选择器不能包含中文
+
+#### 数据库规范
+
+- 迁移脚本必须幂等(重复执行不报错)
+- 加列/建表/索引用 try-catch 忽略"已存在"错误
+- 每次迁移后同步更新 `schema.sql` 的完整定义
+
+#### 时间显示规范
+
+- 后端返回 ISO 8601 格式(`2026-08-15T14:30:00`)
+- 展示时间:`yyyy-MM-dd HH:mm:ss`
+- 仅日期:`yyyy-MM-dd`
+- 禁止使用 `new Date(str).toLocaleString()`
+
+---
+
+## 外部依赖与阻塞项
+
+| 编号 | 依赖项 | 影响范围 | 状态 |
+|---|---|---|---|
+| CR-004 | 校友白名单/邀请码数据 | 阻塞首课报名 | 待提供 |
+| CR-023 | L0 招生海报真实报名二维码 | 阻塞招生 | 待提供 |
+| CR-024 | 浠艾福菌群/DNA 脱敏样本 3-5 份 | 阻塞首课 | 待提供 |
+| CR-026 | 商标检索 + 域名排查 + 英文名定稿 | 阻塞品牌对外 | 待确认 |
+
+---
+
+## Git 工作流
+
+### 仓库信息
+
+- **远程地址**:`http://git.iwintrue.com/liaoxg/caremate.git`
+- **主分支**:`master`
+
+### 提交规范
+
+1. **Commit Message 格式**:中文 Conventional 风格
+   ```
+   Phase6: 徽章与成长体系
+   登录统一: 浮层登录+全局守卫+storage兜底
+   ```
+
+2. **提交流程**:
+   ```bash
+   git status                    # 确认改动范围
+   git diff                      # 检查具体变更
+   git add <目标文件>            # 只 stage 目标文件
+   git diff --cached --stat      # 确认 staged 内容
+   git commit -m "PhaseX: 描述"  # 提交
+   ```
+
+3. **编译验证**:
+   ```bash
+   cd train-backend && mvn clean compile  # 编译通过即提交
+   ```
+
+### 注意事项
+
+- JWT 密钥已与 cfc 对齐,**不要改回独立密钥**
+- 品牌名固定为「爱伴·AI之旅」(CareMate · AI Journey),**勿回退为旧称**
+- 本机 MySQL 通常不可达,**编译通过就提交**,不要因连不上库而卡住
+
+### Windows/PowerShell 编码陷阱
+
+PowerShell 把 UTF-8 当 GBK 解码,`git diff` / 终端输出的中文会显示为乱码——**是假乱码**。判断文件编码/JSON 合法性用 Node 或 read/grep 工具,不要信 PowerShell 的显示。
+
+---
+
+## 产品愿景
+
+### V3.3 解决方案要点
+
+1. **沙龙做引流**:4 类主题轮播 + 半日制 20 分钟流程,比做产物划算
+2. **模块化教学**:L1/L2 模块化半天制 + L0 高频滚动(每周 ≥ 4 期),降低报名门槛
+3. **插班制**:模块化能成立的唯一前提
+4. **五段位成长主线**:见习 → 启航 → 专精 → 系统师 → 讲师
+5. **徽章体系**:32 枚徽章(12 模块 + 5 课程 + 6 技能 + 5 里程碑 + 4 讲师)
+6. **讲师资格阶梯**:T1-T4(评教/合规笔试 2 年有效/课时费 200-1200)
+
+### 完整漏斗
+
+```
+沙龙引流 → L0 高频(家立方-AI管家成长营)
+         → L1 模块化 → L2 系统 → L3 陪跑 → L4 讲师
+```
+
+---
+
+## 文档
+
+详细设计文档、SRS 需求规格、需求变更记录、合规核查结果等,参见 `docs/` 目录。
+
+AI Agent 工作规范参见 `AGENTS.md`。
+
+---
+
+*最后更新:2026-09*

+ 24 - 0
train-backend/src/main/java/com/train/controller/CourseController.java

@@ -5,10 +5,12 @@ import com.train.common.Result;
 import com.train.entity.TrainClass;
 import com.train.entity.TrainCourse;
 import com.train.entity.TrainEnrollment;
+import com.train.entity.TrainModule;
 import com.train.entity.TrainUser;
 import com.train.mapper.TrainClassMapper;
 import com.train.mapper.TrainCourseMapper;
 import com.train.mapper.TrainEnrollmentMapper;
+import com.train.mapper.TrainModuleMapper;
 import com.train.mapper.TrainUserMapper;
 import com.train.service.CertService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -59,6 +61,28 @@ public class CourseController {
     @Resource
     private CertService certService;
 
+    @Resource
+    private TrainModuleMapper trainModuleMapper;
+
+    /**
+     * 课程模块列表(学员端:课程主页展示模块进度)。
+     * 仅返回启用模块,按 sort 升序;moduleKey/track/version 供前端渲染模块编号与版本。
+     */
+    @Operation(summary = "课程模块列表(学员端)")
+    @PostMapping("/modules")
+    public Result<List<TrainModule>> modules(@RequestBody Map<String, Object> body) {
+        if (body == null || body.get("courseId") == null) {
+            return Result.error("缺少课程ID");
+        }
+        Long courseId = Long.valueOf(body.get("courseId").toString());
+        return Result.success(trainModuleMapper.selectList(
+                new LambdaQueryWrapper<TrainModule>()
+                        .eq(TrainModule::getCourseId, courseId)
+                        .eq(TrainModule::getEnabled, 1)
+                        .orderByAsc(TrainModule::getSort)
+                        .orderByAsc(TrainModule::getId)));
+    }
+
     @Operation(summary = "列出所有课程(含有效状态与我的进班状态,未登录可浏览,我的状态为 none)")
     @PostMapping("/list")
     public Result<List<TrainCourse>> list(@RequestAttribute(value = "userId", required = false) Long userId) {

+ 9 - 4
train-backend/src/main/java/com/train/controller/admin/AdminCourseController.java

@@ -37,11 +37,16 @@ public class AdminCourseController {
     @Resource
     private TrainCourseSectionMapper trainCourseSectionMapper;
 
-    @Operation(summary = "课程列表(含各班次数量/可分配班次)")
+    @Operation(summary = "课程列表(含各班次数量/可分配班次,可按学习路径过滤)")
     @PostMapping("/list")
-    public Result<List<TrainCourse>> list() {
-        return Result.success(trainCourseMapper.selectList(
-                new LambdaQueryWrapper<TrainCourse>().orderByAsc(TrainCourse::getLevel)));
+    public Result<List<TrainCourse>> list(@RequestBody(required = false) Map<String, Object> body) {
+        LambdaQueryWrapper<TrainCourse> wrapper = new LambdaQueryWrapper<TrainCourse>()
+                .orderByAsc(TrainCourse::getLevel);
+        if (body != null && body.get("track") != null
+                && StringUtils.hasText(body.get("track").toString())) {
+            wrapper.eq(TrainCourse::getTrack, body.get("track").toString());
+        }
+        return Result.success(trainCourseMapper.selectList(wrapper));
     }
 
     @Operation(summary = "新建课程")

+ 15 - 0
train-backend/src/main/java/com/train/controller/admin/AdminModuleController.java

@@ -63,6 +63,9 @@ public class AdminModuleController {
             return Result.error("模块名称不能为空");
         }
         module.setName(module.getName().trim());
+        if (module.getVersion() == null || module.getVersion().trim().isEmpty()) {
+            module.setVersion("1.0");
+        }
         if (module.getPrice() == null) {
             module.setPrice(0);
         }
@@ -93,9 +96,21 @@ public class AdminModuleController {
         if (module.getDescription() != null) {
             exist.setDescription(module.getDescription());
         }
+        if (module.getModuleKey() != null) {
+            exist.setModuleKey(module.getModuleKey());
+        }
         if (module.getTrack() != null) {
             exist.setTrack(module.getTrack());
         }
+        if (module.getOutputDesc() != null) {
+            exist.setOutputDesc(module.getOutputDesc());
+        }
+        if (module.getVersion() != null) {
+            exist.setVersion(module.getVersion());
+        }
+        if (module.getDuration() != null) {
+            exist.setDuration(module.getDuration());
+        }
         if (module.getPrice() != null) {
             exist.setPrice(module.getPrice());
         }

+ 12 - 0
train-backend/src/main/java/com/train/entity/TrainModule.java

@@ -24,6 +24,9 @@ public class TrainModule implements Serializable {
     /** 所属课程ID(train_course.id) */
     private Long courseId;
 
+    /** 模块编号:A1/A2/H1/H2/G1/G2/B1-B4(规格原子模块标识) */
+    private String moduleKey;
+
     /** 学习路径/轨道:wealth/health/growth(空=通用模块) */
     private String track;
 
@@ -33,6 +36,15 @@ public class TrainModule implements Serializable {
     /** 模块说明 */
     private String description;
 
+    /** 产出要求(完成本模块的交付物) */
+    private String outputDesc;
+
+    /** 模块版本号(插班按版本判定补修,如 1.2) */
+    private String version;
+
+    /** 时长描述(半天/1天) */
+    private String duration;
+
     /** 模块报名价(分,插班按模块收费;展示时除以 100) */
     private Integer price;
 

+ 9 - 0
train-backend/src/main/resources/schema.sql

@@ -39,15 +39,20 @@ CREATE TABLE IF NOT EXISTS train_course_section (
 CREATE TABLE IF NOT EXISTS train_module (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     course_id BIGINT NOT NULL COMMENT '所属课程ID(train_course.id)',
+    module_key VARCHAR(20) COMMENT '模块编号:A1/A2/H1/H2/G1/G2/B1-B4(规格原子模块标识)',
     track VARCHAR(20) COMMENT '学习路径/轨道:wealth/health/growth(空=通用模块)',
     name VARCHAR(100) NOT NULL COMMENT '模块名称(如:L1-A1 财富诊断)',
     description VARCHAR(500) COMMENT '模块说明',
+    output_desc VARCHAR(500) COMMENT '产出要求(完成本模块的交付物)',
+    version VARCHAR(20) DEFAULT '1.0' COMMENT '模块版本号(插班按版本判定补修,如 1.2)',
+    duration VARCHAR(50) COMMENT '时长描述(半天/1天)',
     price INT DEFAULT 0 COMMENT '模块报名价(分,插班按模块收费;展示除以100)',
     sort INT DEFAULT 0 COMMENT '排序(升序)',
     enabled TINYINT DEFAULT 1 COMMENT '1=启用,0=禁用',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_course_id (course_id),
+    INDEX idx_module_key (module_key),
     INDEX idx_sort (sort)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程模块(模块化教学/插班)';
 
@@ -106,6 +111,10 @@ ALTER TABLE train_course ADD COLUMN price INT DEFAULT 0 COMMENT '课程报名价
 ALTER TABLE train_course ADD COLUMN member_price INT DEFAULT 0 COMMENT '课程会员价(分)';
 ALTER TABLE train_course ADD COLUMN track VARCHAR(20) COMMENT '学习路径/轨道:wealth/health/growth(模块化方向,空=主线通用课程如 L0)';
 ALTER TABLE train_enrollment ADD COLUMN course_id BIGINT COMMENT '报名所属课程 ID(train_course.id,报名=报课程;班次仅确定上课期次)';
+ALTER TABLE train_module ADD COLUMN module_key VARCHAR(20) COMMENT '模块编号:A1/A2/H1/H2/G1/G2/B1-B4(规格原子模块标识)';
+ALTER TABLE train_module ADD COLUMN output_desc VARCHAR(500) COMMENT '产出要求(完成本模块的交付物)';
+ALTER TABLE train_module ADD COLUMN version VARCHAR(20) DEFAULT '1.0' COMMENT '模块版本号(插班按版本判定补修,如 1.2)';
+ALTER TABLE train_module ADD COLUMN duration VARCHAR(50) COMMENT '时长描述(半天/1天)';
 
 -- 小组表
 CREATE TABLE IF NOT EXISTS train_group (

+ 1 - 1
train-frontend/pages/badge/index.vue

@@ -22,7 +22,7 @@
           v-for="(badge, bIdx) in group.list" 
           :key="bIdx"
           @click="handleBadgeClick(badge)"
-          :style="badge.earned ? { backgroundColor: badge.color } : {}"
+          :style="badge.earned ? 'background-color:' + badge.color : ''"
         >
           <view class="badge-content">
             <text class="badge-icon" :class="badge.earned ? 'icon-earned' : ''">{{ badge.earned ? '✓' : '🔒' }}</text>

+ 47 - 1
train-frontend/pages/course/detail.vue

@@ -49,6 +49,27 @@
         <text class="class-info-time" v-if="classTime">上课时间:{{ classTime }}</text>
         <text class="class-info-place" v-if="classPlace">上课地点:{{ classPlace }}</text>
       </view>
+
+      <!-- 模块列表 -->
+      <view class="module-section" v-if="modules && modules.length > 0">
+        <view class="section-title-block">课程模块</view>
+        <view class="module-list">
+          <view class="module-item" v-for="(mod, idx) in modules" :key="idx">
+            <view class="module-main">
+              <text class="module-key">{{ mod.moduleKey }}</text>
+              <text class="module-title">{{ mod.name }}</text>
+              <text class="module-version" v-if="mod.version">v{{ mod.version }}</text>
+            </view>
+            <text class="module-output" v-if="mod.outputDesc">{{ mod.outputDesc }}</text>
+            <text class="module-output" v-if="mod.description && !mod.outputDesc">{{ mod.description }}</text>
+            <view class="module-footer">
+              <text class="module-duration" v-if="mod.duration">时长:{{ mod.duration }}</text>
+              <text class="module-duration" v-if="mod.price > 0">模块价 ¥{{ fenToYuan(mod.price) }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
       <!-- 课程前:课前准备 -->
       <view class="stage-card" v-if="stage === 'upcoming'">
         <text class="stage-icon">📅</text>
@@ -95,7 +116,7 @@
 </template>
 
 <script>
-import { getMyCourse, getCourseProgress, enterCourse } from '@/utils/api.js'
+import { getMyCourse, getCourseProgress, enterCourse, getCourseModules } from '@/utils/api.js'
 import { formatDateTime } from '@/utils/format.js'
 import { requireLogin } from '@/utils/guard.js'
 
@@ -108,6 +129,7 @@ export default {
       classPlace: '',
       enrollment: null,
       progress: { done: 0, total: 0, items: [] },
+      modules: [],
       enterLoading: false
     }
   },
@@ -179,6 +201,14 @@ export default {
         self.classPlace = d.classPlace || ''
         self.enrollment = d.enrollment || null
       }).catch(function() {})
+      
+      // 获取模块列表
+      if (self.courseId) {
+        getCourseModules(self.courseId).then(function(resp) {
+          self.modules = resp.data || []
+        }).catch(function() {})
+      }
+
       // 学习进度需登录后请求,未登录浏览时不调用(避免 401 误触发登录过期弹窗)
       if (self.isLoggedIn) {
         getCourseProgress().then(function(resp) {
@@ -242,6 +272,9 @@ export default {
       if (!v) return '-'
       return formatDateTime(v)
     },
+    fenToYuan(fen) {
+      return ((fen || 0) / 100).toFixed(2)
+    },
     goTo(url) {
       // 去报名/去支付为实际操作,未登录先引导登录(课程信息浏览已放行)
       if (!requireLogin()) return
@@ -270,6 +303,19 @@ export default {
 .course-time { font-size: 24rpx; color: #94A3B8; margin-bottom: 8rpx; }
 .course-class { font-size: 24rpx; color: #1E293B; background: #F1F5F9; border-radius: 8rpx; padding: 8rpx 16rpx; align-self: flex-start; }
 
+.module-section { margin-bottom: 24rpx; }
+.module-list { display: flex; flex-direction: column; gap: 20rpx; }
+.module-item { background: #FFF; border-radius: 16rpx; padding: 24rpx; border: 1px solid #E2E8F0; }
+.module-main { display: flex; align-items: center; margin-bottom: 12rpx; }
+.module-key { font-size: 20rpx; color: #F97316; background: #FFF7ED; padding: 2rpx 8rpx; border-radius: 4rpx; margin-right: 12rpx; font-weight: 600; }
+.module-title { font-size: 28rpx; font-weight: 700; color: #1E293B; flex: 1; }
+.module-version { font-size: 22rpx; color: #94A3B8; }
+.module-output { font-size: 24rpx; color: #64748B; line-height: 1.5; margin-bottom: 16rpx; }
+.module-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #F1F5F9; padding-top: 16rpx; }
+.module-duration { font-size: 22rpx; color: #94A3B8; }
+.module-status { font-size: 22rpx; font-weight: 600; color: #94A3B8; }
+.module-status.done { color: #22C55E; }
+
 .class-info-card { background: #FFF; border-radius: 16rpx; padding: 28rpx 32rpx; margin-bottom: 24rpx; display: flex; flex-direction: column; }
 .class-info-title { font-size: 24rpx; color: #94A3B8; margin-bottom: 8rpx; }
 .class-info-name { font-size: 30rpx; font-weight: 700; color: #1E293B; margin-bottom: 8rpx; }

+ 3 - 0
train-frontend/utils/api.js

@@ -126,6 +126,9 @@ export const enterCourse = (courseId) => {
 export const getCourseProgress = () => {
   return request('/api/course/progress', 'POST')
 }
+export const getCourseModules = (courseId) => {
+  return request('/api/course/modules', 'POST', { courseId })
+}
 
 // 分组
 export const joinGroup = (groupId) => {

+ 19 - 2
train-web/src/api/course.js

@@ -2,8 +2,8 @@ import request from '@/utils/request'
 
 // 课程管理(L0/L1/L2/L3/L4)
 
-export function courseList() {
-  return request.post('/api/admin/course/list', {})
+export function courseList(params) {
+  return request.post('/api/admin/course/list', params || {})
 }
 
 export function createCourse(data) {
@@ -33,4 +33,21 @@ export function updateSection(data) {
 
 export function deleteSection(id) {
   return request.post('/api/admin/course/section/delete', { id })
+}
+
+// 课程模块维护(Phase1 模块化基础)
+export function moduleList(courseId) {
+  return request.post('/api/admin/module/list', { courseId })
+}
+
+export function createModule(data) {
+  return request.post('/api/admin/module/create', data)
+}
+
+export function updateModule(data) {
+  return request.post('/api/admin/module/update', data)
+}
+
+export function deleteModule(id) {
+  return request.post('/api/admin/module/delete', { id })
 }

+ 7 - 1
train-web/src/router/index.js

@@ -35,12 +35,18 @@ const routes = [
         component: () => import('@/views/Courses.vue'),
         meta: { title: '课程管理' }
       },
+      {
+        path: 'modules',
+        name: 'Modules',
+        component: () => import('@/views/Modules.vue'),
+        meta: { title: '模块管理' }
+      },
       {
         path: 'classes',
         name: 'Classes',
         component: () => import('@/views/Classes.vue'),
         meta: { title: '班级管理' }
-      },
+      }
       {
         path: 'salons',
         name: 'Salons',

+ 115 - 75
train-web/src/views/Courses.vue

@@ -1,13 +1,28 @@
 <template>
   <div class="page-container">
     <div class="page-header">
-      <h2 class="page-title">课程管理</h2>
+      <div class="header-left">
+        <h2 class="page-title">课程管理</h2>
+      <el-select v-model="filter.track" placeholder="筛选赛道" style="width:150px; margin-left: 20px" clearable @change="fetchList">
+        <el-option label="财富" value="wealth" />
+        <el-option label="健康" value="health" />
+        <el-option label="成长" value="growth" />
+      </el-select>
+      </div>
       <el-button type="primary" icon="el-icon-plus" @click="openCreate">新建课程</el-button>
     </div>
 
     <el-table :data="list" v-loading="loading" border stripe style="width:100%">
       <el-table-column prop="id" label="ID" width="80" />
       <el-table-column prop="level" label="等级" width="80" />
+      <el-table-column label="赛道" width="80">
+        <template slot-scope="{ row }">
+          <span v-if="row.track === 'wealth'">财富</span>
+          <span v-else-if="row.track === 'health'">健康</span>
+          <span v-else-if="row.track === 'growth'">成长</span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="name" label="课程名称" min-width="180" />
       <el-table-column prop="description" label="简介" min-width="220" show-overflow-tooltip />
       <el-table-column label="报名价" width="100">
@@ -33,23 +48,31 @@
         </template>
       </el-table-column>
       <el-table-column prop="createdAt" label="创建时间" width="170" :formatter="formatTime" />
-      <el-table-column label="操作" width="150" fixed="right">
-        <template slot-scope="{ row }">
-          <el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
-          <el-button size="mini" @click="openSections(row)">版块</el-button>
-        </template>
-      </el-table-column>
+       <el-table-column label="操作" width="180" fixed="right">
+         <template slot-scope="{ row }">
+           <el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
+           <el-button size="mini" @click="openModules(row)">模块</el-button>
+           <el-button size="mini" @click="openSections(row)">版块</el-button>
+         </template>
+       </el-table-column>
     </el-table>
 
     <!-- 新建/编辑课程弹窗 -->
     <el-dialog :title="form.id ? '编辑课程' : '新建课程'" :visible.sync="showDialog" width="560px" @close="resetForm">
       <el-form :model="form" :rules="rules" ref="form" label-width="90px">
-        <el-form-item label="等级" prop="level">
-          <el-select v-model="form.level" placeholder="选择课程等级" style="width:100%">
-            <el-option v-for="lv in ['L0','L1','L2','L3','L4']" :key="lv" :label="lv" :value="lv" />
+         <el-form-item label="等级" prop="level">
+           <el-select v-model="form.level" placeholder="选择课程等级" style="width:100%">
+             <el-option v-for="lv in ['L0','L1','L2','L3','L4']" :key="lv" :label="lv" :value="lv" />
+           </el-select>
+         </el-form-item>
+        <el-form-item label="赛道" prop="track" v-if="form.level !== 'L0'">
+          <el-select v-model="form.track" placeholder="选择赛道(空=通用主线)" clearable style="width:100%">
+            <el-option label="财富" value="wealth" />
+            <el-option label="健康" value="health" />
+            <el-option label="成长" value="growth" />
           </el-select>
         </el-form-item>
-        <el-form-item label="课程名称" prop="name">
+         <el-form-item label="课程名称" prop="name">
           <el-input v-model="form.name" placeholder="如:家立方-AI管家成长营" />
         </el-form-item>
         <el-form-item label="简介">
@@ -143,26 +166,31 @@ function fenToYuan(fen) {
 export default {
   name: 'Courses',
   data: function () {
-    return {
-      list: [],
-      loading: false,
-      showDialog: false,
-      submitting: false,
-      form: {
-        id: null,
-        level: 'L0',
-        name: '',
-        description: '',
-        price: 0,
-        memberPrice: 0,
-        startTime: null,
-        endTime: null,
-        status: 'draft'
-      },
-      rules: {
-        level: [{ required: true, message: '请选择课程等级', trigger: 'change' }],
-        name: [{ required: true, message: '请输入课程名称', trigger: 'blur' }]
-      },
+     return {
+       list: [],
+       loading: false,
+       filter: {
+         track: ''
+       },
+       showDialog: false,
+       submitting: false,
+       form: {
+         id: null,
+         level: 'L0',
+         track: '',
+         name: '',
+         description: '',
+         price: 0,
+         memberPrice: 0,
+         startTime: null,
+         endTime: null,
+         status: 'draft'
+       },
+       rules: {
+         level: [{ required: true, message: '请选择课程等级', trigger: 'change' }],
+         track: [{ required: true, message: '请选择赛道', trigger: 'change' }],
+         name: [{ required: true, message: '请输入课程名称', trigger: 'blur' }]
+       },
       // 课程版块维护
       showSectionDialog: false,
       sectionCourse: null,
@@ -201,54 +229,62 @@ export default {
       var map = { draft: '草稿', active: '启用', finished: '已结束' }
       return map[s] || s || '-'
     },
-    fetchList: function () {
-      var self = this
-      self.loading = true
-      courseList().then(function (res) {
-        self.list = res.data || []
-      }).catch(function () {
-        self.$message.error('获取课程列表失败')
-      }).finally(function () {
-        self.loading = false
-      })
-    },
+     fetchList: function () {
+       var self = this
+       self.loading = true
+       var params = {
+         track: self.filter.track
+       }
+       courseList(params).then(function (res) {
+         self.list = res.data || []
+       }).catch(function () {
+         self.$message.error('获取课程列表失败')
+       }).finally(function () {
+         self.loading = false
+       })
+     },
     openCreate: function () {
       this.resetForm()
       this.showDialog = true
     },
-    openEdit: function (row) {
-      this.form = {
-        id: row.id,
-        level: row.level,
-        name: row.name,
-        description: row.description,
-        price: row.price || 0,
-        memberPrice: row.memberPrice || 0,
-        startTime: row.startTime || null,
-        endTime: row.endTime || null,
-        status: row.status || 'draft'
-      }
-      this.showDialog = true
-    },
-    resetForm: function () {
-      this.form = { id: null, level: 'L0', name: '', description: '', price: 0, memberPrice: 0, startTime: null, endTime: null, status: 'draft' }
-    },
+     openEdit: function (row) {
+       this.form = {
+         id: row.id,
+         level: row.level,
+         track: row.track || '',
+         name: row.name,
+         description: row.description,
+         price: row.price || 0,
+         memberPrice: row.memberPrice || 0,
+         startTime: row.startTime || null,
+         endTime: row.endTime || null,
+         status: row.status || 'draft'
+       }
+       this.showDialog = true
+     },
+     openModules: function (row) {
+       this.$router.push({ path: '/modules', query: { courseId: row.id } })
+     },
+     resetForm: function () {
+       this.form = { id: null, level: 'L0', track: '', name: '', description: '', price: 0, memberPrice: 0, startTime: null, endTime: null, status: 'draft' }
+     },
     handleSubmit: function () {
       var self = this
       if (self.submitting) return
       self.$refs.form.validate(function (valid) {
         if (!valid) return
         self.submitting = true
-        var payload = {
-          level: self.form.level,
-          name: self.form.name,
-          description: self.form.description,
-          price: self.form.price,
-          memberPrice: self.form.memberPrice,
-          startTime: self.form.startTime,
-          endTime: self.form.endTime,
-          status: self.form.status
-        }
+         var payload = {
+           level: self.form.level,
+           track: self.form.track,
+           name: self.form.name,
+           description: self.form.description,
+           price: self.form.price,
+           memberPrice: self.form.memberPrice,
+           startTime: self.form.startTime,
+           endTime: self.form.endTime,
+           status: self.form.status
+         }
         var p
         if (self.form.id) {
           payload.id = self.form.id
@@ -348,12 +384,16 @@ export default {
 </script>
 
 <style scoped>
-.page-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16px;
-}
+ .page-header {
+   display: flex;
+   justify-content: space-between;
+   align-items: center;
+   margin-bottom: 16px;
+ }
+ .header-left {
+   display: flex;
+   align-items: center;
+ }
 
 .page-title {
   margin: 0;

+ 271 - 0
train-web/src/views/Modules.vue

@@ -0,0 +1,271 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <div class="header-left">
+        <h2 class="page-title">模块管理</h2>
+        <el-breadcrumb style="margin-left: 20px; font-size: 13px">
+          <el-breadcrumb-item :to="{ path: '/courses' }">课程管理</el-breadcrumb-item>
+          <el-breadcrumb-item>{{ currentCourseName || ('课程ID ' + currentCourseId) }}</el-breadcrumb-item>
+        </el-breadcrumb>
+      </div>
+      <el-button type="primary" icon="el-icon-plus" @click="openCreate">新建模块</el-button>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe style="width:100%">
+      <el-table-column prop="moduleKey" label="模块编号" width="100" />
+      <el-table-column prop="track" label="路径" width="90">
+        <template slot-scope="{ row }">
+          <el-tag v-if="row.track" size="small">{{ trackLabel(row.track) }}</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="name" label="模块名称" min-width="150" />
+      <el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
+      <el-table-column prop="outputDesc" label="产出要求" min-width="180" show-overflow-tooltip />
+      <el-table-column prop="version" label="版本" width="80" />
+      <el-table-column prop="duration" label="时长" width="80" />
+      <el-table-column label="模块价" width="90">
+        <template slot-scope="{ row }">
+          <span v-if="row.price > 0">¥{{ fenToYuan(row.price) }}</span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="sort" label="排序" width="70" />
+      <el-table-column label="启用" width="80">
+        <template slot-scope="{ row }">
+          <el-tag :type="row.enabled === 1 ? 'success' : 'info'" size="small">
+            {{ row.enabled === 1 ? '启用' : '禁用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="150" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
+          <el-button size="mini" type="danger" @click="remove(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog :title="form.id ? '编辑模块' : '新建模块'" :visible.sync="showDialog" width="560px" @close="resetForm">
+      <el-form :model="form" :rules="rules" ref="form" label-width="100px">
+        <el-form-item label="模块编号" prop="moduleKey">
+          <el-input v-model="form.moduleKey" placeholder="如:A1/A2/H1/G1/B1(规格原子模块标识)" />
+        </el-form-item>
+        <el-form-item label="路径" prop="track">
+          <el-select v-model="form.track" placeholder="选择学习路径" style="width:100%" clearable>
+            <el-option label="财富" value="wealth" />
+            <el-option label="健康" value="health" />
+            <el-option label="成长" value="growth" />
+            <el-option label="通用" value="" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="模块名称" prop="name">
+          <el-input v-model="form.name" placeholder="如:L1-A1 财富诊断" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="form.description" type="textarea" :rows="2" placeholder="模块详细描述" />
+        </el-form-item>
+        <el-form-item label="产出要求">
+          <el-input v-model="form.outputDesc" type="textarea" :rows="2" placeholder="完成本模块的交付物(规格判定标准)" />
+        </el-form-item>
+        <el-form-item label="版本号">
+          <el-input v-model="form.version" placeholder="如:1.0 / 1.2(插班按版本补修)" />
+        </el-form-item>
+        <el-form-item label="时长">
+          <el-input v-model="form.duration" placeholder="如:半天 / 1天" />
+        </el-form-item>
+        <el-form-item label="模块价">
+          <el-input-number v-model="form.price" :min="0" :max="100000000" :step="100" />
+          <span class="tips">单位:分(插班按模块收费)</span>
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sort" :min="0" :max="9999" />
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="showDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { moduleList, createModule, updateModule, deleteModule, courseList } from '@/api/course'
+
+function fenToYuan(fen) {
+  return (fen / 100).toFixed(2)
+}
+
+export default {
+  name: 'Modules',
+  data: function () {
+    return {
+      list: [],
+      loading: false,
+      currentCourseId: '',
+      currentCourseName: '',
+      showDialog: false,
+      submitting: false,
+      form: {
+        id: null,
+        moduleKey: '',
+        track: '',
+        name: '',
+        description: '',
+        outputDesc: '',
+        version: '1.0',
+        duration: '',
+        price: 0,
+        sort: 0,
+        enabled: 1
+      },
+      rules: {
+        moduleKey: [{ required: false }],
+        name: [{ required: true, message: '请输入模块名称', trigger: 'blur' }]
+      }
+    }
+  },
+  mounted: function () {
+    this.currentCourseId = this.$route.query.courseId
+    if (!this.currentCourseId) {
+      this.$message.error('缺少课程ID')
+      this.$router.push('/courses')
+      return
+    }
+    this.loadCourseName()
+    this.fetchList()
+  },
+  methods: {
+    trackLabel: function (t) {
+      var map = { wealth: '财富', health: '健康', growth: '成长' }
+      return map[t] || t
+    },
+    loadCourseName: function () {
+      var self = this
+      courseList().then(function (res) {
+        var courses = res.data || []
+        for (var i = 0; i < courses.length; i++) {
+          if (String(courses[i].id) === String(self.currentCourseId)) {
+            self.currentCourseName = courses[i].level + ' ' + courses[i].name
+            return
+          }
+        }
+      }).catch(function () {})
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      moduleList(self.currentCourseId).then(function (res) {
+        self.list = res.data || []
+      }).catch(function () {
+        self.$message.error('获取模块列表失败')
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    openCreate: function () {
+      this.resetForm()
+      this.showDialog = true
+    },
+    openEdit: function (row) {
+      this.form = {
+        id: row.id,
+        moduleKey: row.moduleKey || '',
+        track: row.track || '',
+        name: row.name,
+        description: row.description || '',
+        outputDesc: row.outputDesc || '',
+        version: row.version || '1.0',
+        duration: row.duration || '',
+        price: row.price || 0,
+        sort: row.sort || 0,
+        enabled: row.enabled === 1 ? 1 : 0
+      }
+      this.showDialog = true
+    },
+    resetForm: function () {
+      this.form = {
+        id: null,
+        moduleKey: '',
+        track: '',
+        name: '',
+        description: '',
+        outputDesc: '',
+        version: '1.0',
+        duration: '',
+        price: 0,
+        sort: 0,
+        enabled: 1
+      }
+    },
+    handleSubmit: function () {
+      var self = this
+      if (self.submitting) return
+      self.$refs.form.validate(function (valid) {
+        if (!valid) return
+        self.submitting = true
+        var payload = {
+          id: self.form.id,
+          courseId: self.currentCourseId,
+          moduleKey: self.form.moduleKey,
+          track: self.form.track,
+          name: self.form.name,
+          description: self.form.description,
+          outputDesc: self.form.outputDesc,
+          version: self.form.version,
+          duration: self.form.duration,
+          price: self.form.price,
+          sort: self.form.sort,
+          enabled: self.form.enabled
+        }
+        var p = self.form.id ? updateModule(payload) : createModule(payload)
+        p.then(function () {
+          self.$message.success('保存成功')
+          self.showDialog = false
+          self.fetchList()
+        }).catch(function () {
+          self.$message.error('保存失败')
+        }).finally(function () {
+          self.submitting = false
+        })
+      })
+    },
+    remove: function (row) {
+      var self = this
+      self.$confirm('确认删除模块「' + row.name + '」?', '提示', { type: 'warning' }).then(function () {
+        return deleteModule(row.id)
+      }).then(function () {
+        self.$message.success('删除成功')
+        self.fetchList()
+      }).catch(function () {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+.header-left {
+  display: flex;
+  align-items: center;
+}
+.page-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+.tips {
+  font-size: 12px;
+  color: #909399;
+  margin-left: 8px;
+}
+</style>