Browse Source

Phase14: 团报发票与案例授权

liaoxg 1 week ago
parent
commit
afb30a1010
26 changed files with 1963 additions and 0 deletions
  1. 99 0
      train-backend/src/main/java/com/train/controller/CaseController.java
  2. 269 0
      train-backend/src/main/java/com/train/controller/GroupOrderController.java
  3. 137 0
      train-backend/src/main/java/com/train/controller/admin/AdminCaseController.java
  4. 120 0
      train-backend/src/main/java/com/train/controller/admin/AdminGroupOrderController.java
  5. 28 0
      train-backend/src/main/java/com/train/entity/TrainCase.java
  6. 35 0
      train-backend/src/main/java/com/train/entity/TrainGroupOrder.java
  7. 26 0
      train-backend/src/main/java/com/train/entity/TrainGroupOrderMember.java
  8. 7 0
      train-backend/src/main/java/com/train/mapper/TrainCaseMapper.java
  9. 7 0
      train-backend/src/main/java/com/train/mapper/TrainGroupOrderMapper.java
  10. 7 0
      train-backend/src/main/java/com/train/mapper/TrainGroupOrderMemberMapper.java
  11. 133 0
      train-backend/src/main/java/com/train/service/PayService.java
  12. 54 0
      train-backend/src/main/resources/schema.sql
  13. 24 0
      train-frontend/pages.json
  14. 102 0
      train-frontend/pages/case/index.vue
  15. 204 0
      train-frontend/pages/group-order/create.vue
  16. 150 0
      train-frontend/pages/group-order/detail.vue
  17. 89 0
      train-frontend/pages/group-order/index.vue
  18. 10 0
      train-frontend/pages/mine/index.vue
  19. 22 0
      train-frontend/utils/api.js
  20. 13 0
      train-web/src/api/case.js
  21. 9 0
      train-web/src/api/groupOrder.js
  22. 2 0
      train-web/src/config/menu-roles.js
  23. 12 0
      train-web/src/router/index.js
  24. 226 0
      train-web/src/views/Cases.vue
  25. 170 0
      train-web/src/views/GroupOrders.vue
  26. 8 0
      train-web/src/views/Layout.vue

+ 99 - 0
train-backend/src/main/java/com/train/controller/CaseController.java

@@ -0,0 +1,99 @@
+package com.train.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.entity.TrainCase;
+import com.train.entity.TrainClass;
+import com.train.mapper.TrainCaseMapper;
+import com.train.mapper.TrainClassMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 学员案例授权接口(FR-FISS-04):营员查看待授权案例并同意/拒绝对外展示。
+ * <p>案例由后台采集(AdminCaseController.create,status=pending),
+ * 流程序列:pending(待授权) → authorized(已授权)/rejected(已拒绝) → published(后台发布展示)。</p>
+ */
+@Slf4j
+@Tag(name = "案例授权", description = "学员案例查看与授权")
+@RestController
+@RequestMapping("/api/case")
+public class CaseController {
+
+    @Resource
+    private TrainCaseMapper trainCaseMapper;
+    @Resource
+    private TrainClassMapper trainClassMapper;
+
+    /**
+     * 我的案例列表(后台采集后在此展示,供授权/拒绝)
+     */
+    @Operation(summary = "我的案例列表")
+    @PostMapping("/mine")
+    public Result<List<Map<String, Object>>> mine(@RequestAttribute("userId") Long userId) {
+        List<TrainCase> list = trainCaseMapper.selectList(
+                new LambdaQueryWrapper<TrainCase>()
+                        .eq(TrainCase::getUid, userId)
+                        .orderByDesc(TrainCase::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainCase c : list) {
+            result.add(toRow(c));
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * 授权/拒绝:agree=true → authorized(记录 consentAt);false → rejected
+     */
+    @Operation(summary = "授权/拒绝案例")
+    @PostMapping("/decide")
+    public Result<Boolean> decide(@RequestBody Map<String, Object> body,
+                                  @RequestAttribute("userId") Long userId) {
+        Object idObj = body.get("caseId");
+        if (idObj == null) {
+            return Result.error("缺少案例ID");
+        }
+        TrainCase c = trainCaseMapper.selectById(Long.valueOf(idObj.toString()));
+        if (c == null || !userId.equals(c.getUid())) {
+            return Result.error("案例不存在");
+        }
+        if ("published".equals(c.getStatus())) {
+            return Result.error("案例已发布,不可再变更授权");
+        }
+        boolean agree = body.get("agree") != null && Boolean.parseBoolean(body.get("agree").toString());
+        c.setStatus(agree ? "authorized" : "rejected");
+        c.setConsentAt(new Date());
+        c.setUpdatedAt(new Date());
+        trainCaseMapper.updateById(c);
+        log.info("学员{}案例授权: caseId={}, agree={}", userId, c.getId(), agree);
+        return Result.success(true);
+    }
+
+    private Map<String, Object> toRow(TrainCase c) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("id", c.getId());
+        row.put("classId", c.getClassId());
+        TrainClass cl = c.getClassId() == null ? null : trainClassMapper.selectById(c.getClassId());
+        row.put("className", cl != null ? cl.getName() : null);
+        row.put("title", c.getTitle());
+        row.put("summary", c.getSummary());
+        row.put("sourceSubmissionId", c.getSourceSubmissionId());
+        row.put("status", c.getStatus());
+        row.put("consentAt", c.getConsentAt());
+        row.put("createdAt", c.getCreatedAt());
+        return row;
+    }
+}

+ 269 - 0
train-backend/src/main/java/com/train/controller/GroupOrderController.java

@@ -0,0 +1,269 @@
+package com.train.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.cfc.entity.CfcActivity;
+import com.train.cfc.mapper.CfcActivityMapper;
+import com.train.entity.TrainClass;
+import com.train.entity.TrainEnrollment;
+import com.train.entity.TrainGroupOrder;
+import com.train.entity.TrainGroupOrderMember;
+import com.train.mapper.TrainClassMapper;
+import com.train.mapper.TrainEnrollmentMapper;
+import com.train.mapper.TrainGroupOrderMapper;
+import com.train.mapper.TrainGroupOrderMemberMapper;
+import com.train.service.PayService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * 团报接口(FR-PAY-03):多人一单 + 企业开票信息采集。
+ * <p>流程:create 创建团报单(含成员名单与发票信息)→ pay 下单支付(test-mode 直付)→ 入账时逐人生成报名单。</p>
+ */
+@Slf4j
+@Tag(name = "团报", description = "团报下单与支付")
+@RestController
+@RequestMapping("/api/group-order")
+public class GroupOrderController {
+
+    @Resource
+    private TrainGroupOrderMapper trainGroupOrderMapper;
+    @Resource
+    private TrainGroupOrderMemberMapper trainGroupOrderMemberMapper;
+    @Resource
+    private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainEnrollmentMapper trainEnrollmentMapper;
+    @Resource
+    private CfcActivityMapper cfcActivityMapper;
+    @Resource
+    private PayService payService;
+
+    /** 占用名额的报名状态 */
+    private static final List<String> OCCUPY_STATUS = Arrays.asList("pending", "paid", "confirmed");
+
+    /**
+     * 创建团报单(含成员名单与开票信息),单价取 cfc activities 会员价。
+     */
+    @Operation(summary = "创建团报单")
+    @PostMapping("/create")
+    public Result<Map<String, Object>> create(@RequestBody Map<String, Object> body,
+                                              @RequestAttribute("userId") Long userId) {
+        Object classIdObj = body.get("classId");
+        if (classIdObj == null) {
+            return Result.error("请选择班次");
+        }
+        Long classId = Long.valueOf(classIdObj.toString());
+        TrainClass trainClass = trainClassMapper.selectById(classId);
+        if (trainClass == null || !"active".equals(trainClass.getStatus())) {
+            return Result.error("班次不存在或不可报名");
+        }
+        // 成员名单
+        Object membersObj = body.get("members");
+        if (!(membersObj instanceof List) || ((List<?>) membersObj).isEmpty()) {
+            return Result.error("请填写团报成员名单");
+        }
+        List<Map<String, Object>> memberList = new ArrayList<>();
+        @SuppressWarnings("unchecked")
+        List<Object> rawMembers = (List<Object>) membersObj;
+        for (Object m : rawMembers) {
+            if (!(m instanceof Map)) {
+                continue;
+            }
+            @SuppressWarnings("unchecked")
+            Map<String, Object> mm = (Map<String, Object>) m;
+            String name = mm.get("name") == null ? "" : mm.get("name").toString().trim();
+            String phone = mm.get("phone") == null ? "" : mm.get("phone").toString().trim();
+            if (!StringUtils.hasText(name) || !StringUtils.hasText(phone)) {
+                return Result.error("成员姓名和手机号均不能为空");
+            }
+            Map<String, Object> row = new HashMap<>();
+            row.put("name", name);
+            row.put("phone", phone);
+            memberList.add(row);
+        }
+        if (memberList.isEmpty()) {
+            return Result.error("请填写团报成员名单");
+        }
+        // 容量校验
+        Long occupied = trainEnrollmentMapper.selectCount(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getClassId, classId)
+                        .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
+        long newCount = memberList.size();
+        if (trainClass.getCapacity() != null && occupied != null
+                && occupied + newCount > trainClass.getCapacity()) {
+            return Result.error("该班次剩余名额不足,无法按当前人数团报");
+        }
+        // 单价(会员价优先,与报名接口一致)
+        int price = 0;
+        if (trainClass.getActivityId() != null) {
+            CfcActivity activity = cfcActivityMapper.selectById(trainClass.getActivityId());
+            if (activity != null) {
+                int raw = activity.getPrice() == null ? 0 : activity.getPrice();
+                price = activity.getMemberPrice() == null ? raw : activity.getMemberPrice();
+            }
+        }
+        // 开票信息
+        String invoiceTitle = body.get("invoiceTitle") == null ? "" : body.get("invoiceTitle").toString().trim();
+        String invoiceType = body.get("invoiceType") == null ? "普票" : body.get("invoiceType").toString().trim();
+        if (StringUtils.hasText(invoiceTitle)) {
+            if (!StringUtils.hasText(body.get("invoiceTaxNo") == null ? "" : body.get("invoiceTaxNo").toString())) {
+                return Result.error("填写发票抬头后请一并填写税号");
+            }
+        }
+
+        TrainGroupOrder go = new TrainGroupOrder();
+        go.setGroupNo(genGroupNo());
+        go.setClassId(classId);
+        go.setLeaderUid(userId);
+        go.setMemberCount(memberList.size());
+        go.setUnitAmount(price);
+        go.setTotalAmount(price * memberList.size());
+        go.setStatus("pending");
+        go.setInvoiceTitle(invoiceTitle);
+        go.setInvoiceTaxNo(body.get("invoiceTaxNo") == null ? null : body.get("invoiceTaxNo").toString().trim());
+        go.setInvoiceEmail(body.get("invoiceEmail") == null ? null : body.get("invoiceEmail").toString().trim());
+        go.setInvoicePhone(body.get("invoicePhone") == null ? null : body.get("invoicePhone").toString().trim());
+        go.setInvoiceType(invoiceType);
+        go.setCreatedAt(new Date());
+        go.setUpdatedAt(new Date());
+        trainGroupOrderMapper.insert(go);
+
+        for (Map<String, Object> m : memberList) {
+            TrainGroupOrderMember mem = new TrainGroupOrderMember();
+            mem.setGroupOrderId(go.getId());
+            mem.setName(m.get("name").toString());
+            mem.setPhone(m.get("phone").toString());
+            mem.setStatus("pending");
+            mem.setCreatedAt(new Date());
+            trainGroupOrderMemberMapper.insert(mem);
+        }
+
+        Map<String, Object> row = new HashMap<>();
+        row.put("id", go.getId());
+        row.put("groupNo", go.getGroupNo());
+        row.put("classId", classId);
+        row.put("className", trainClass.getName());
+        row.put("memberCount", memberList.size());
+        row.put("unitAmount", price);
+        row.put("totalAmount", go.getTotalAmount());
+        row.put("status", go.getStatus());
+        log.info("创建团报单: id={}, groupNo={}, classId={}, members={}, total={}",
+                go.getId(), go.getGroupNo(), classId, memberList.size(), go.getTotalAmount());
+        return Result.success(row);
+    }
+
+    /**
+     * 我的团报单列表(团长视角)
+     */
+    @Operation(summary = "我的团报单列表")
+    @PostMapping("/mine")
+    public Result<List<Map<String, Object>>> mine(@RequestAttribute("userId") Long userId) {
+        List<TrainGroupOrder> list = trainGroupOrderMapper.selectList(
+                new LambdaQueryWrapper<TrainGroupOrder>()
+                        .eq(TrainGroupOrder::getLeaderUid, userId)
+                        .orderByDesc(TrainGroupOrder::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainGroupOrder go : list) {
+            result.add(toRow(go));
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * 团报单详情(含成员名单;须为团长本人)
+     */
+    @Operation(summary = "团报单详情")
+    @PostMapping("/detail")
+    public Result<Map<String, Object>> detail(@RequestBody Map<String, Object> body,
+                                              @RequestAttribute("userId") Long userId) {
+        Object idObj = body.get("id");
+        if (idObj == null) {
+            return Result.error("缺少团报单ID");
+        }
+        TrainGroupOrder go = trainGroupOrderMapper.selectById(Long.valueOf(idObj.toString()));
+        if (go == null || !userId.equals(go.getLeaderUid())) {
+            return Result.error("团报单不存在");
+        }
+        Map<String, Object> row = toRow(go);
+        List<TrainGroupOrderMember> members = trainGroupOrderMemberMapper.selectList(
+                new LambdaQueryWrapper<TrainGroupOrderMember>()
+                        .eq(TrainGroupOrderMember::getGroupOrderId, go.getId())
+                        .orderByAsc(TrainGroupOrderMember::getId));
+        List<Map<String, Object>> memberRows = new ArrayList<>();
+        for (TrainGroupOrderMember m : members) {
+            Map<String, Object> mm = new HashMap<>();
+            mm.put("id", m.getId());
+            mm.put("name", m.getName());
+            mm.put("phone", m.getPhone());
+            mm.put("status", m.getStatus());
+            mm.put("enrollmentId", m.getEnrollmentId());
+            memberRows.add(mm);
+        }
+        row.put("members", memberRows);
+        return Result.success(row);
+    }
+
+    /**
+     * 团报支付(test-mode 直接入账:订单置 paid + 逐成员生成报名单)
+     */
+    @Operation(summary = "团报支付")
+    @PostMapping("/pay")
+    public Result<Map<String, Object>> pay(@RequestBody Map<String, Object> body,
+                                           @RequestAttribute("userId") Long userId) {
+        Object idObj = body.get("id");
+        if (idObj == null) {
+            return Result.error("缺少团报单ID");
+        }
+        try {
+            return Result.success(payService.createGroupOrderPay(Long.valueOf(idObj.toString()), userId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    private Map<String, Object> toRow(TrainGroupOrder go) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("id", go.getId());
+        row.put("groupNo", go.getGroupNo());
+        row.put("classId", go.getClassId());
+        TrainClass c = go.getClassId() == null ? null : trainClassMapper.selectById(go.getClassId());
+        row.put("className", c != null ? c.getName() : null);
+        row.put("leaderUid", go.getLeaderUid());
+        row.put("memberCount", go.getMemberCount());
+        row.put("unitAmount", go.getUnitAmount());
+        row.put("totalAmount", go.getTotalAmount());
+        row.put("orderId", go.getOrderId());
+        row.put("status", go.getStatus());
+        row.put("invoiceTitle", go.getInvoiceTitle());
+        row.put("invoiceType", go.getInvoiceType());
+        row.put("payTime", go.getPayTime());
+        row.put("createdAt", go.getCreatedAt());
+        return row;
+    }
+
+    /** 生成团报单号:GR + yyyyMMddHHmmss + 4位随机 */
+    private String genGroupNo() {
+        String ts = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
+        int rand = ThreadLocalRandom.current().nextInt(1000, 10000);
+        return "GR" + ts + rand;
+    }
+}

+ 137 - 0
train-backend/src/main/java/com/train/controller/admin/AdminCaseController.java

@@ -0,0 +1,137 @@
+package com.train.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.entity.TrainCase;
+import com.train.entity.TrainClass;
+import com.train.entity.TrainUser;
+import com.train.mapper.TrainCaseMapper;
+import com.train.mapper.TrainClassMapper;
+import com.train.mapper.TrainUserMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端-案例采集与发布(FR-FISS-04)。
+ * <p>流程:create 采集案例(脱敏内容,status=pending)→ 学员端授权/拒绝 → publish 发布已授权案例。</p>
+ */
+@Slf4j
+@Tag(name = "管理端-案例", description = "学员案例采集、列表与发布")
+@RestController
+@RequestMapping("/api/admin/case")
+public class AdminCaseController {
+
+    @Resource
+    private TrainCaseMapper trainCaseMapper;
+    @Resource
+    private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainUserMapper trainUserMapper;
+
+    /**
+     * 采集案例(脱敏后入库,待学员授权)
+     */
+    @Operation(summary = "采集案例")
+    @PostMapping("/create")
+    public Result<Boolean> create(@RequestBody Map<String, Object> body,
+                                  @RequestAttribute("adminId") Long adminId) {
+        Object uidObj = body.get("uid");
+        if (uidObj == null) {
+            return Result.error("请选择案例学员");
+        }
+        String title = body.get("title") == null ? "" : body.get("title").toString().trim();
+        String summary = body.get("summary") == null ? "" : body.get("summary").toString().trim();
+        if (!StringUtils.hasText(title) || !StringUtils.hasText(summary)) {
+            return Result.error("请填写案例标题与内容");
+        }
+        TrainCase c = new TrainCase();
+        c.setUid(Long.valueOf(uidObj.toString()));
+        c.setClassId(body.get("classId") == null ? null
+                : Long.valueOf(body.get("classId").toString()));
+        c.setTitle(title);
+        c.setSummary(summary);
+        c.setSourceSubmissionId(body.get("sourceSubmissionId") == null ? null
+                : Long.valueOf(body.get("sourceSubmissionId").toString()));
+        c.setStatus("pending");
+        c.setCreatedAt(new Date());
+        c.setUpdatedAt(new Date());
+        trainCaseMapper.insert(c);
+        log.info("后台采集案例: caseId={}, uid={}, title={}", c.getId(), c.getUid(), title);
+        return Result.success(true);
+    }
+
+    /**
+     * 案例列表(支持按状态筛选)
+     */
+    @Operation(summary = "案例列表")
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list(@RequestBody(required = false) Map<String, Object> body,
+                                                  @RequestAttribute("adminId") Long adminId) {
+        LambdaQueryWrapper<TrainCase> qw = new LambdaQueryWrapper<>();
+        if (body != null && body.get("status") != null
+                && !body.get("status").toString().trim().isEmpty()) {
+            qw.eq(TrainCase::getStatus, body.get("status").toString());
+        }
+        qw.orderByDesc(TrainCase::getId);
+        List<TrainCase> list = trainCaseMapper.selectList(qw);
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainCase c : list) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", c.getId());
+            row.put("uid", c.getUid());
+            TrainUser u = c.getUid() == null ? null : trainUserMapper.selectById(c.getUid());
+            row.put("userName", u != null ? u.getName() : null);
+            row.put("userPhone", u != null ? u.getPhone() : null);
+            row.put("classId", c.getClassId());
+            TrainClass cl = c.getClassId() == null ? null : trainClassMapper.selectById(c.getClassId());
+            row.put("className", cl != null ? cl.getName() : null);
+            row.put("title", c.getTitle());
+            row.put("summary", c.getSummary());
+            row.put("sourceSubmissionId", c.getSourceSubmissionId());
+            row.put("status", c.getStatus());
+            row.put("consentAt", c.getConsentAt());
+            row.put("createdAt", c.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * 发布案例(仅已授权可发布;发布后不可再变更授权)
+     */
+    @Operation(summary = "发布案例")
+    @PostMapping("/publish")
+    public Result<Boolean> publish(@RequestBody Map<String, Object> body,
+                                   @RequestAttribute("adminId") Long adminId) {
+        Object idObj = body.get("caseId");
+        if (idObj == null) {
+            return Result.error("缺少案例ID");
+        }
+        TrainCase c = trainCaseMapper.selectById(Long.valueOf(idObj.toString()));
+        if (c == null) {
+            return Result.error("案例不存在");
+        }
+        if (!"authorized".equals(c.getStatus())) {
+            return Result.error("仅已授权案例可发布");
+        }
+        c.setStatus("published");
+        c.setUpdatedAt(new Date());
+        trainCaseMapper.updateById(c);
+        log.info("发布案例: caseId={}", c.getId());
+        return Result.success(true);
+    }
+}

+ 120 - 0
train-backend/src/main/java/com/train/controller/admin/AdminGroupOrderController.java

@@ -0,0 +1,120 @@
+package com.train.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.entity.TrainClass;
+import com.train.entity.TrainGroupOrder;
+import com.train.entity.TrainGroupOrderMember;
+import com.train.entity.TrainUser;
+import com.train.mapper.TrainClassMapper;
+import com.train.mapper.TrainGroupOrderMapper;
+import com.train.mapper.TrainGroupOrderMemberMapper;
+import com.train.mapper.TrainUserMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端-团报管理(FR-PAY-03):团报单列表 / 成员名单 / 开票信息查看。
+ */
+@Tag(name = "管理端-团报", description = "团报单与开票信息管理")
+@RestController
+@RequestMapping("/api/admin/group-order")
+public class AdminGroupOrderController {
+
+    @Resource
+    private TrainGroupOrderMapper trainGroupOrderMapper;
+    @Resource
+    private TrainGroupOrderMemberMapper trainGroupOrderMemberMapper;
+    @Resource
+    private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainUserMapper trainUserMapper;
+
+    /**
+     * 团报单列表(支持按班次/状态筛选)
+     */
+    @Operation(summary = "团报单列表")
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list(@RequestBody(required = false) Map<String, Object> body,
+                                                  @RequestAttribute("adminId") Long adminId) {
+        LambdaQueryWrapper<TrainGroupOrder> qw = new LambdaQueryWrapper<>();
+        if (body != null && body.get("classId") != null) {
+            qw.eq(TrainGroupOrder::getClassId, Long.valueOf(body.get("classId").toString()));
+        }
+        if (body != null && body.get("status") != null
+                && !body.get("status").toString().trim().isEmpty()) {
+            qw.eq(TrainGroupOrder::getStatus, body.get("status").toString());
+        }
+        qw.orderByDesc(TrainGroupOrder::getId);
+        List<TrainGroupOrder> list = trainGroupOrderMapper.selectList(qw);
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainGroupOrder go : list) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", go.getId());
+            row.put("groupNo", go.getGroupNo());
+            row.put("classId", go.getClassId());
+            TrainClass c = go.getClassId() == null ? null : trainClassMapper.selectById(go.getClassId());
+            row.put("className", c != null ? c.getName() : null);
+            row.put("leaderUid", go.getLeaderUid());
+            TrainUser u = go.getLeaderUid() == null ? null : trainUserMapper.selectById(go.getLeaderUid());
+            row.put("leaderName", u != null ? u.getName() : null);
+            row.put("leaderPhone", u != null ? u.getPhone() : null);
+            row.put("memberCount", go.getMemberCount());
+            row.put("unitAmount", go.getUnitAmount());
+            row.put("totalAmount", go.getTotalAmount());
+            row.put("orderId", go.getOrderId());
+            row.put("status", go.getStatus());
+            row.put("invoiceTitle", go.getInvoiceTitle());
+            row.put("invoiceTaxNo", go.getInvoiceTaxNo());
+            row.put("invoiceEmail", go.getInvoiceEmail());
+            row.put("invoicePhone", go.getInvoicePhone());
+            row.put("invoiceType", go.getInvoiceType());
+            row.put("payTime", go.getPayTime());
+            row.put("createdAt", go.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * 团报单成员名单
+     */
+    @Operation(summary = "团报单成员名单")
+    @PostMapping("/members")
+    public Result<List<Map<String, Object>>> members(@RequestBody Map<String, Object> body,
+                                                     @RequestAttribute("adminId") Long adminId) {
+        Object idObj = body.get("id");
+        if (idObj == null) {
+            return Result.error("缺少团报单ID");
+        }
+        List<TrainGroupOrderMember> list = trainGroupOrderMemberMapper.selectList(
+                new LambdaQueryWrapper<TrainGroupOrderMember>()
+                        .eq(TrainGroupOrderMember::getGroupOrderId, Long.valueOf(idObj.toString()))
+                        .orderByAsc(TrainGroupOrderMember::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainGroupOrderMember m : list) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", m.getId());
+            row.put("name", m.getName());
+            row.put("phone", m.getPhone());
+            row.put("uid", m.getUid());
+            TrainUser u = m.getUid() == null ? null : trainUserMapper.selectById(m.getUid());
+            row.put("userName", u != null ? u.getName() : null);
+            row.put("enrollmentId", m.getEnrollmentId());
+            row.put("status", m.getStatus());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+}

+ 28 - 0
train-backend/src/main/java/com/train/entity/TrainCase.java

@@ -0,0 +1,28 @@
+package com.train.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/** 学员案例(FR-FISS-04:采集→脱敏→授权→回流) */
+@Data
+@TableName("train_case")
+public class TrainCase implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long uid; // 案例学员ID
+    private Long classId;
+    private String title;
+    private String summary; // 脱敏后的案例内容(不含真实数据)
+    private Long sourceSubmissionId; // 来源成果(train_submission.id)
+    private String status; // pending待授权/authorized已授权/rejected已拒绝/published已发布
+    private Date consentAt; // 学员授权时间
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 35 - 0
train-backend/src/main/java/com/train/entity/TrainGroupOrder.java

@@ -0,0 +1,35 @@
+package com.train.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/** 团报订单(FR-PAY-03:多学员一单 + 企业开票信息采集) */
+@Data
+@TableName("train_group_order")
+public class TrainGroupOrder implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String groupNo;
+    private Long classId;
+    private Long leaderUid;
+    private Integer memberCount;
+    private Integer unitAmount; // 单价(分)
+    private Integer totalAmount; // 总金额(分)
+    private Long orderId; // 关联支付订单(train_order.id)
+    private String status; // pending/paid/closed
+    private String invoiceTitle; // 发票抬头(企业名称)
+    private String invoiceTaxNo; // 税号
+    private String invoiceEmail; // 电子发票接收邮箱
+    private String invoicePhone; // 收票人电话
+    private String invoiceType; // 普票/专票
+    private Date payTime;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 26 - 0
train-backend/src/main/java/com/train/entity/TrainGroupOrderMember.java

@@ -0,0 +1,26 @@
+package com.train.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/** 团报成员(FR-PAY-03:团报单下的学员名单,支付成功后逐人生成报名单) */
+@Data
+@TableName("train_group_order_member")
+public class TrainGroupOrderMember implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long groupOrderId;
+    private String name;
+    private String phone;
+    private Long uid; // 对应学员ID(已注册则回填)
+    private Long enrollmentId; // 支付成功后生成的报名单ID
+    private String status; // pending/paid
+    private Date createdAt;
+}

+ 7 - 0
train-backend/src/main/java/com/train/mapper/TrainCaseMapper.java

@@ -0,0 +1,7 @@
+package com.train.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.train.entity.TrainCase;
+
+public interface TrainCaseMapper extends BaseMapper<TrainCase> {
+}

+ 7 - 0
train-backend/src/main/java/com/train/mapper/TrainGroupOrderMapper.java

@@ -0,0 +1,7 @@
+package com.train.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.train.entity.TrainGroupOrder;
+
+public interface TrainGroupOrderMapper extends BaseMapper<TrainGroupOrder> {
+}

+ 7 - 0
train-backend/src/main/java/com/train/mapper/TrainGroupOrderMemberMapper.java

@@ -0,0 +1,7 @@
+package com.train.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.train.entity.TrainGroupOrderMember;
+
+public interface TrainGroupOrderMemberMapper extends BaseMapper<TrainGroupOrderMember> {
+}

+ 133 - 0
train-backend/src/main/java/com/train/service/PayService.java

@@ -6,11 +6,15 @@ import com.train.cfc.mapper.CfcActivityMapper;
 import com.train.controller.PlanController;
 import com.train.entity.TrainClass;
 import com.train.entity.TrainEnrollment;
+import com.train.entity.TrainGroupOrder;
+import com.train.entity.TrainGroupOrderMember;
 import com.train.entity.TrainInvite;
 import com.train.entity.TrainOrder;
 import com.train.entity.TrainUser;
 import com.train.mapper.TrainClassMapper;
 import com.train.mapper.TrainEnrollmentMapper;
+import com.train.mapper.TrainGroupOrderMapper;
+import com.train.mapper.TrainGroupOrderMemberMapper;
 import com.train.mapper.TrainInviteMapper;
 import com.train.mapper.TrainOrderMapper;
 import com.train.mapper.TrainUserMapper;
@@ -48,6 +52,10 @@ public class PayService {
     @Resource
     private TrainInviteMapper trainInviteMapper;
     @Resource
+    private TrainGroupOrderMapper trainGroupOrderMapper;
+    @Resource
+    private TrainGroupOrderMemberMapper trainGroupOrderMemberMapper;
+    @Resource
     private CfcActivityMapper cfcActivityMapper;
     @Resource
     private PlanController planController;
@@ -181,6 +189,19 @@ public class PayService {
         order.setUpdatedAt(new Date());
         trainOrderMapper.updateById(order);
 
+        // 团报订单(无 enrollment_id):反查团报单并逐成员入账
+        if (order.getEnrollmentId() == null) {
+            TrainGroupOrder go = trainGroupOrderMapper.selectOne(
+                    new LambdaQueryWrapper<TrainGroupOrder>()
+                            .eq(TrainGroupOrder::getOrderId, order.getId())
+                            .last("LIMIT 1"));
+            if (go != null) {
+                completeGroupPaid(go, order);
+                log.info("团报支付入账完成: groupOrderId={}, orderNo={}", go.getId(), orderNo);
+                return;
+            }
+        }
+
         // 报名单置 paid,并自动进班(class_id 回填)
         if (order.getEnrollmentId() != null) {
             TrainEnrollment en = trainEnrollmentMapper.selectById(order.getEnrollmentId());
@@ -338,6 +359,118 @@ public class PayService {
         return closed;
     }
 
+    /**
+     * 团报支付:为团报单生成支付订单(test-mode 直接入账)。
+     * @param groupOrderId 团报单ID
+     * @param userId 当前操作用户(须为团长)
+     * @return {orderNo, amount, status, payTime, prepayParams}
+     */
+    @Transactional
+    public Map<String, Object> createGroupOrderPay(Long groupOrderId, Long userId) {
+        TrainGroupOrder go = trainGroupOrderMapper.selectById(groupOrderId);
+        if (go == null) {
+            throw new RuntimeException("团报单不存在");
+        }
+        if (!userId.equals(go.getLeaderUid())) {
+            throw new RuntimeException("无权支付该团报单");
+        }
+        if ("paid".equals(go.getStatus())) {
+            throw new RuntimeException("该团报单已支付");
+        }
+        // 幂等:已有关联未关闭订单直接返回
+        if (go.getOrderId() != null) {
+            TrainOrder exist = trainOrderMapper.selectById(go.getOrderId());
+            if (exist != null && "unpaid".equals(exist.getStatus())) {
+                return buildOrderResult(exist);
+            }
+        }
+        TrainOrder order = new TrainOrder();
+        order.setOrderNo(genOrderNo());
+        order.setEnrollmentId(null);
+        order.setUid(userId);
+        order.setClassId(go.getClassId());
+        order.setAmount(go.getTotalAmount());
+        order.setOriginalAmount(go.getTotalAmount());
+        order.setDiscountAmount(0);
+        order.setPayChannel("wechat");
+        order.setStatus("unpaid");
+        order.setRefundAmount(0);
+        order.setCreatedAt(new Date());
+        order.setUpdatedAt(new Date());
+        trainOrderMapper.insert(order);
+
+        // 回填团报单与订单的关联
+        go.setOrderId(order.getId());
+        trainGroupOrderMapper.updateById(go);
+
+        if (testMode) {
+            log.info("【测试模式】模拟团报支付成功: groupOrderId={}, orderNo={}, amount={}",
+                    groupOrderId, order.getOrderNo(), order.getAmount());
+            completeGroupPaid(go, order);
+        }
+        TrainOrder saved = trainOrderMapper.selectById(order.getId());
+        return buildOrderResult(saved);
+    }
+
+    /**
+     * 团报入账:团报单置 paid,为每个成员生成报名单(paid),
+     * 手机号已注册学员回填 uid 并自动进班(class_id 仅当为空时回填)。
+     */
+    private void completeGroupPaid(TrainGroupOrder go, TrainOrder order) {
+        order.setStatus("paid");
+        order.setPayTime(new Date());
+        order.setWechatTxnId("TEST" + System.currentTimeMillis());
+        order.setUpdatedAt(new Date());
+        trainOrderMapper.updateById(order);
+
+        java.util.List<TrainGroupOrderMember> members = trainGroupOrderMemberMapper.selectList(
+                new LambdaQueryWrapper<TrainGroupOrderMember>()
+                        .eq(TrainGroupOrderMember::getGroupOrderId, go.getId()));
+        for (TrainGroupOrderMember m : members) {
+            if ("paid".equals(m.getStatus()) && m.getEnrollmentId() != null) {
+                continue; // 重复入账保护
+            }
+            Long uid = null;
+            if (StringUtils.hasText(m.getPhone())) {
+                TrainUser user = trainUserMapper.selectOne(
+                        new LambdaQueryWrapper<TrainUser>()
+                                .eq(TrainUser::getPhone, m.getPhone())
+                                .last("LIMIT 1"));
+                if (user != null) {
+                    uid = user.getId();
+                    // 已注册学员自动进班
+                    if (user.getClassId() == null) {
+                        user.setClassId(go.getClassId());
+                        user.setUpdatedAt(new Date());
+                        trainUserMapper.updateById(user);
+                        log.info("团报成员自动进班: uid={}, classId={}", user.getId(), go.getClassId());
+                    }
+                }
+            }
+            TrainEnrollment en = new TrainEnrollment();
+            en.setUid(uid);
+            en.setClassId(go.getClassId());
+            en.setName(m.getName());
+            en.setPhone(m.getPhone());
+            en.setSource("group");
+            en.setStatus("paid");
+            en.setCreatedAt(new Date());
+            en.setUpdatedAt(new Date());
+            trainEnrollmentMapper.insert(en);
+
+            m.setUid(uid);
+            m.setEnrollmentId(en.getId());
+            m.setStatus("paid");
+            trainGroupOrderMemberMapper.updateById(m);
+            log.info("团报成员入账完成: groupOrderId={}, memberId={}, enrollmentId={}",
+                    go.getId(), m.getId(), en.getId());
+        }
+
+        go.setStatus("paid");
+        go.setPayTime(new Date());
+        trainGroupOrderMapper.updateById(go);
+    }
+
     private Map<String, Object> buildOrderResult(TrainOrder order) {
         Map<String, Object> row = new HashMap<>();
         row.put("orderNo", order.getOrderNo());

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

@@ -356,3 +356,57 @@ CREATE TABLE IF NOT EXISTS train_screen_config (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_class_id (class_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级投屏配置';
+
+-- 团报订单表(FR-PAY-03:多学员一单 + 企业开票信息采集)
+CREATE TABLE IF NOT EXISTS train_group_order (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    group_no VARCHAR(32) NOT NULL UNIQUE COMMENT '团报单号',
+    class_id BIGINT NOT NULL COMMENT '班次ID',
+    leader_uid BIGINT NOT NULL COMMENT '发起人(train_user.id)',
+    member_count INT DEFAULT 1 COMMENT '报名人数',
+    unit_amount INT COMMENT '单价(分)',
+    total_amount INT NOT NULL COMMENT '总金额(分)',
+    order_id BIGINT COMMENT '关联支付订单(train_order.id)',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paid/closed',
+    invoice_title VARCHAR(200) COMMENT '发票抬头(企业名称)',
+    invoice_tax_no VARCHAR(100) COMMENT '税号',
+    invoice_email VARCHAR(100) COMMENT '电子发票接收邮箱',
+    invoice_phone VARCHAR(50) COMMENT '收票人电话',
+    invoice_type VARCHAR(20) COMMENT '普票/专票',
+    pay_time DATETIME COMMENT '支付时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_class_id (class_id),
+    INDEX idx_leader_uid (leader_uid),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团报订单(多学员一单+开票)';
+
+-- 团报成员表(FR-PAY-03:一个团报单下的学员名单,支付成功后逐人生成报名单)
+CREATE TABLE IF NOT EXISTS train_group_order_member (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    group_order_id BIGINT NOT NULL COMMENT '团报单ID',
+    name VARCHAR(50) NOT NULL COMMENT '学员姓名',
+    phone VARCHAR(11) COMMENT '手机号',
+    uid BIGINT COMMENT '对应学员ID(已注册则回填)',
+    enrollment_id BIGINT COMMENT '支付成功后生成的报名单ID',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paid',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_group_order_id (group_order_id),
+    INDEX idx_uid (uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团报成员';
+
+-- 学员案例表(FR-FISS-04:采集→脱敏→授权→回流)
+CREATE TABLE IF NOT EXISTS train_case (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    uid BIGINT NOT NULL COMMENT '案例学员ID',
+    class_id BIGINT COMMENT '班次ID',
+    title VARCHAR(100) NOT NULL COMMENT '案例标题',
+    summary TEXT COMMENT '脱敏后的案例内容(不含真实数据)',
+    source_submission_id BIGINT COMMENT '来源成果(train_submission.id)',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending待授权/authorized已授权/rejected已拒绝/published已发布',
+    consent_at DATETIME COMMENT '学员授权时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_uid (uid),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学员案例(采集/授权/回流)';

+ 24 - 0
train-frontend/pages.json

@@ -132,6 +132,30 @@
       "style": {
         "navigationBarTitleText": "课前资料"
       }
+    },
+    {
+      "path": "pages/group-order/index",
+      "style": {
+        "navigationBarTitleText": "团报"
+      }
+    },
+    {
+      "path": "pages/group-order/create",
+      "style": {
+        "navigationBarTitleText": "发起团报"
+      }
+    },
+    {
+      "path": "pages/group-order/detail",
+      "style": {
+        "navigationBarTitleText": "团报详情"
+      }
+    },
+    {
+      "path": "pages/case/index",
+      "style": {
+        "navigationBarTitleText": "案例授权"
+      }
     }
   ],
   "globalStyle": {

+ 102 - 0
train-frontend/pages/case/index.vue

@@ -0,0 +1,102 @@
+<template>
+  <view class="case-page">
+    <view class="section-card">
+      <text class="section-title">案例授权</text>
+      <text class="card-desc">教学案例将展示您的姓名与执业信息,请确认是否同意授权展示</text>
+
+      <view v-if="loading" class="empty-tip">加载中…</view>
+      <view v-else-if="list.length === 0" class="empty-tip">暂无需要授权的案例</view>
+      <view v-else>
+        <view class="case-item" v-for="item in list" :key="item.id">
+          <text class="case-title">{{ item.title }}</text>
+          <text class="case-summary">{{ item.summary }}</text>
+          <view class="case-foot">
+            <text class="case-status" :class="item.status">{{ statusLabel(item.status) }}</text>
+            <view class="case-actions" v-if="item.status === 'pending'">
+              <text class="action-btn reject" @click="decide(item, false)">拒绝</text>
+              <text class="action-btn agree" @click="decide(item, true)">同意授权</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMyCases, decideCase } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false,
+      deciding: false
+    }
+  },
+  onShow() {
+    this.loadMine()
+  },
+  methods: {
+    loadMine() {
+      var self = this
+      self.loading = true
+      getMyCases().then(function(resp) {
+        self.list = resp.data || []
+      }).catch(function() {
+        uni.showToast({ title: '加载案例失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    statusLabel(status) {
+      var map = { pending: '待授权', authorized: '已授权', rejected: '已拒绝', published: '已发布' }
+      return map[status] || status || '未知'
+    },
+    decide(item, agree) {
+      var self = this
+      if (self.deciding) return
+      var text = agree ? '同意授权教学使用该案例?' : '拒绝授权该案例?'
+      uni.showModal({
+        title: '案例授权',
+        content: text,
+        success: function(res) {
+          if (!res.confirm) return
+          self.deciding = true
+          decideCase({ caseId: item.id, agree: agree }).then(function() {
+            uni.showToast({ title: agree ? '已授权' : '已拒绝', icon: 'success' })
+            self.loadMine()
+          }).catch(function(err) {
+            uni.showToast({ title: (err && err.message) || '操作失败', icon: 'none' })
+          }).finally(function() {
+            self.deciding = false
+          })
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.case-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
+.section-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; }
+.section-title { display: block; font-size: 32rpx; font-weight: 700; color: #1E293B; margin-bottom: 8rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
+.card-desc { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 24rpx; }
+.empty-tip { font-size: 26rpx; color: #94A3B8; padding: 32rpx 0; text-align: center; }
+.case-item { padding: 24rpx 0; border-bottom: 1rpx solid #F1F5F9; }
+.case-item:last-child { border-bottom: none; }
+.case-title { display: block; font-size: 28rpx; color: #1E293B; font-weight: 600; margin-bottom: 8rpx; }
+.case-summary { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 16rpx; }
+.case-foot { display: flex; align-items: center; justify-content: space-between; }
+.case-status { font-size: 24rpx; font-weight: 600; }
+.case-status.pending { color: #F97316; }
+.case-status.authorized { color: #3B82F6; }
+.case-status.rejected { color: #EF4444; }
+.case-status.published { color: #22C55E; }
+.case-actions { display: flex; align-items: center; }
+.action-btn { font-size: 26rpx; padding: 8rpx 28rpx; border-radius: 28rpx; margin-left: 16rpx; }
+.action-btn.reject { color: #EF4444; border: 2rpx solid #FECACA; }
+.action-btn.agree { background: #F97316; color: #FFF; border: 2rpx solid #F97316; }
+.action-btn:active { opacity: 0.8; }
+</style>

+ 204 - 0
train-frontend/pages/group-order/create.vue

@@ -0,0 +1,204 @@
+<template>
+  <view class="form-page">
+    <view class="section-card">
+      <text class="section-title">发起团报</text>
+      <text class="card-desc">选择班次并填写成员名单(含开票信息,可选)</text>
+
+      <view class="form-item">
+        <text class="form-label">班次</text>
+        <picker mode="selector" :range="classNames" @change="onClassChange">
+          <view class="picker-value" :class="{ placeholder: !classId }">
+            {{ classId ? className : '请选择班次' }}
+          </view>
+        </picker>
+        <view v-if="classId && unitAmount !== null" class="unit-tip">
+          单价 ¥{{ fenToYuan(unitAmount) }},共 {{ members.length }} 人,合计 ¥{{ fenToYuan(unitAmount * members.length) }}
+        </view>
+      </view>
+
+      <view class="form-item">
+        <text class="form-label">成员名单</text>
+        <view class="member-item" v-for="(m, idx) in members" :key="idx">
+          <input class="member-name" v-model="m.name" placeholder="姓名" />
+          <input class="member-phone" v-model="m.phone" type="number" maxlength="11" placeholder="手机号" />
+          <text class="member-del" v-if="members.length > 1" @click="removeMember(idx)">删除</text>
+        </view>
+        <text class="add-member" @click="addMember">+ 添加成员</text>
+      </view>
+
+      <view class="form-item">
+        <text class="form-label">发票抬头(企业名称,选填)</text>
+        <input class="form-input" v-model="invoice.invoiceTitle" placeholder="不填则不开票" />
+      </view>
+      <view class="form-item" v-if="invoice.invoiceTitle">
+        <text class="form-label">税号</text>
+        <input class="form-input" v-model="invoice.invoiceTaxNo" placeholder="统一社会信用代码" />
+      </view>
+      <view class="form-item" v-if="invoice.invoiceTitle">
+        <text class="form-label">开票类型</text>
+        <view class="check-group">
+          <view class="check-item" @click="invoice.invoiceType = '普票'">
+            <text class="check-square" :class="{ checked: invoice.invoiceType === '普票' }">✓</text>
+            <text class="check-text">普票</text>
+          </view>
+          <view class="check-item" @click="invoice.invoiceType = '专票'">
+            <text class="check-square" :class="{ checked: invoice.invoiceType === '专票' }">✓</text>
+            <text class="check-text">专票</text>
+          </view>
+        </view>
+      </view>
+      <view class="form-item" v-if="invoice.invoiceTitle">
+        <text class="form-label">接收邮箱(选填)</text>
+        <input class="form-input" v-model="invoice.invoiceEmail" placeholder="电子发票接收邮箱" />
+      </view>
+      <view class="form-item" v-if="invoice.invoiceTitle">
+        <text class="form-label">收票人电话(选填)</text>
+        <input class="form-input" v-model="invoice.invoicePhone" type="number" maxlength="11" placeholder="收票联系电话" />
+      </view>
+
+      <button class="submit-btn" :loading="loading" :disabled="loading" @click="handleSubmit">提交团报</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getEnrollClasses, createGroupOrder } from '@/utils/api.js'
+import { fenToYuan } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      classes: [],
+      classNames: [],
+      classId: '',
+      className: '',
+      unitAmount: null,
+      members: [{ name: '', phone: '' }],
+      invoice: {
+        invoiceTitle: '',
+        invoiceTaxNo: '',
+        invoiceEmail: '',
+        invoicePhone: '',
+        invoiceType: '普票'
+      },
+      loading: false
+    }
+  },
+  onLoad() {
+    this.loadClasses()
+  },
+  methods: {
+    fenToYuan: fenToYuan,
+    loadClasses() {
+      var self = this
+      getEnrollClasses().then(function(resp) {
+        self.classes = resp.data || []
+        var names = []
+        for (var i = 0; i < self.classes.length; i++) {
+          names.push(self.classes[i].name || ('班次' + self.classes[i].id))
+        }
+        self.classNames = names
+      }).catch(function() {
+        uni.showToast({ title: '加载班次失败', icon: 'none' })
+      })
+    },
+    onClassChange(e) {
+      var idx = Number(e.detail.value)
+      var item = this.classes[idx]
+      if (!item) return
+      this.classId = item.id
+      this.className = item.name
+      // 团报按会员价计价(与后端 create 一致)
+      var price = item.memberPrice > 0 && item.memberPrice < item.price ? item.memberPrice : item.price
+      this.unitAmount = price
+    },
+    addMember() {
+      this.members.push({ name: '', phone: '' })
+    },
+    removeMember(idx) {
+      this.members.splice(idx, 1)
+    },
+    validatePhone(phone) {
+      return /^1\d{10}$/.test(phone)
+    },
+    handleSubmit() {
+      var self = this
+      if (!self.classId) {
+        uni.showToast({ title: '请选择班次', icon: 'none' })
+        return
+      }
+      var memberList = []
+      for (var i = 0; i < self.members.length; i++) {
+        var name = (self.members[i].name || '').trim()
+        var phone = (self.members[i].phone || '').trim()
+        if (!name || !phone) {
+          uni.showToast({ title: '请填写第 ' + (i + 1) + ' 位成员的姓名和手机号', icon: 'none' })
+          return
+        }
+        if (!self.validatePhone(phone)) {
+          uni.showToast({ title: '第 ' + (i + 1) + ' 位成员手机号格式不正确', icon: 'none' })
+          return
+        }
+        memberList.push({ name: name, phone: phone })
+      }
+      if (self.invoice.invoiceTitle && (self.invoice.invoiceTitle || '').trim()) {
+        if (!(self.invoice.invoiceTaxNo || '').trim()) {
+          uni.showToast({ title: '填写发票抬头后请一并填写税号', icon: 'none' })
+          return
+        }
+      }
+      if (self.loading) return
+      self.loading = true
+      var payload = {
+        classId: self.classId,
+        members: memberList,
+        invoiceTitle: (self.invoice.invoiceTitle || '').trim(),
+        invoiceTaxNo: (self.invoice.invoiceTaxNo || '').trim(),
+        invoiceEmail: (self.invoice.invoiceEmail || '').trim(),
+        invoicePhone: (self.invoice.invoicePhone || '').trim(),
+        invoiceType: self.invoice.invoiceType
+      }
+      createGroupOrder(payload).then(function(resp) {
+        var id = resp.data && resp.data.id ? resp.data.id : ''
+        uni.showToast({ title: '团报单已创建', icon: 'success' })
+        setTimeout(function() {
+          if (id) {
+            uni.redirectTo({ url: '/pages/group-order/detail?id=' + id })
+          } else {
+            uni.redirectTo({ url: '/pages/group-order/index' })
+          }
+        }, 1200)
+      }).catch(function(err) {
+        uni.showToast({ title: (err && err.message) || '提交失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.form-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
+.section-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; }
+.section-title { display: block; font-size: 32rpx; font-weight: 700; color: #1E293B; margin-bottom: 8rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
+.card-desc { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 32rpx; }
+.form-item { margin-bottom: 24rpx; }
+.form-label { display: block; font-size: 26rpx; color: #475569; font-weight: 500; margin-bottom: 12rpx; }
+.form-input { height: 72rpx; border: 2rpx solid #E2E8F0; border-radius: 8rpx; padding: 0 16rpx; font-size: 28rpx; color: #1E293B; background: #F8FAFC; }
+.picker-value { height: 72rpx; line-height: 72rpx; border: 2rpx solid #E2E8F0; border-radius: 8rpx; padding: 0 16rpx; font-size: 28rpx; color: #1E293B; background: #F8FAFC; }
+.picker-value.placeholder { color: #94A3B8; }
+.unit-tip { font-size: 24rpx; color: #F97316; margin-top: 12rpx; }
+.member-item { display: flex; align-items: center; margin-bottom: 16rpx; }
+.member-name { width: 200rpx; height: 64rpx; border: 2rpx solid #E2E8F0; border-radius: 8rpx; padding: 0 12rpx; font-size: 26rpx; background: #F8FAFC; margin-right: 12rpx; }
+.member-phone { flex: 1; height: 64rpx; border: 2rpx solid #E2E8F0; border-radius: 8rpx; padding: 0 12rpx; font-size: 26rpx; background: #F8FAFC; margin-right: 12rpx; }
+.member-del { font-size: 24rpx; color: #EF4444; padding: 8rpx; }
+.add-member { display: block; font-size: 26rpx; color: #F97316; font-weight: 600; margin-top: 8rpx; }
+.check-group { display: flex; }
+.check-item { display: flex; align-items: center; margin-right: 40rpx; }
+.check-square { width: 36rpx; height: 36rpx; border: 2rpx solid #CBD5E1; border-radius: 6rpx; display: flex; align-items: center; justify-content: center; font-size: 24rpx; color: #FFF; margin-right: 12rpx; flex-shrink: 0; }
+.check-square.checked { background: #F97316; border-color: #F97316; }
+.check-text { font-size: 26rpx; color: #1E293B; }
+.submit-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: #F97316; color: #FFF; font-size: 30rpx; font-weight: 600; border-radius: 44rpx; border: none; margin-top: 24rpx; }
+.submit-btn:active { opacity: 0.85; }
+</style>

+ 150 - 0
train-frontend/pages/group-order/detail.vue

@@ -0,0 +1,150 @@
+<template>
+  <view class="form-page">
+    <view class="section-card" v-if="detail">
+      <text class="section-title">团报详情</text>
+      <view class="info-row">
+        <text class="info-label">团报单号</text>
+        <text class="info-value">{{ detail.groupNo }}</text>
+      </view>
+      <view class="info-row">
+        <text class="info-label">班次</text>
+        <text class="info-value">{{ detail.className || ('班次' + detail.classId) }}</text>
+      </view>
+      <view class="info-row">
+        <text class="info-label">人数</text>
+        <text class="info-value">{{ detail.memberCount }} 人</text>
+      </view>
+      <view class="info-row">
+        <text class="info-label">单价</text>
+        <text class="info-value">¥{{ fenToYuan(detail.unitAmount) }}</text>
+      </view>
+      <view class="info-row">
+        <text class="info-label">总金额</text>
+        <text class="info-value amount">¥{{ fenToYuan(detail.totalAmount) }}</text>
+      </view>
+      <view class="info-row" v-if="detail.invoiceTitle">
+        <text class="info-label">发票抬头</text>
+        <text class="info-value">{{ detail.invoiceTitle }}({{ detail.invoiceType }})</text>
+      </view>
+      <view class="info-row" v-if="detail.payTime">
+        <text class="info-label">支付时间</text>
+        <text class="info-value">{{ formatDateTime(detail.payTime) }}</text>
+      </view>
+      <view class="info-row">
+        <text class="info-label">状态</text>
+        <text class="info-value" :class="detail.status">{{ statusLabel(detail.status) }}</text>
+      </view>
+
+      <view v-if="detail.status === 'pending'" class="pay-area">
+        <button class="pay-btn" :loading="paying" :disabled="paying" @click="handlePay">立即支付(¥{{ fenToYuan(detail.totalAmount) }})</button>
+        <view class="pay-tip">测试环境将模拟支付成功,支付后为每位成员生成报名单</view>
+      </view>
+      <view v-else-if="detail.status === 'paid'" class="paid-tip">✓ 已支付,全部成员报名单已生成</view>
+    </view>
+
+    <view class="section-card" v-if="members">
+      <text class="section-title">成员名单</text>
+      <view class="member-item" v-for="m in members" :key="m.id">
+        <view class="member-info">
+          <text class="member-name">{{ m.name }}</text>
+          <text class="member-phone">{{ m.phone }}</text>
+        </view>
+        <text class="member-status" :class="m.status">{{ m.status === 'paid' ? '已入账' : '待支付' }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getGroupOrderDetail, payGroupOrder } from '@/utils/api.js'
+import { fenToYuan, formatDateTime } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      id: '',
+      detail: null,
+      members: [],
+      paying: false
+    }
+  },
+  onLoad(options) {
+    this.id = options && options.id ? options.id : ''
+    if (!this.id) {
+      uni.showToast({ title: '缺少团报单ID', icon: 'none' })
+      setTimeout(function() {
+        uni.navigateBack({ delta: 1 })
+      }, 1500)
+      return
+    }
+    this.loadDetail()
+  },
+  methods: {
+    fenToYuan: fenToYuan,
+    formatDateTime: formatDateTime,
+    loadDetail() {
+      var self = this
+      getGroupOrderDetail({ id: self.id }).then(function(resp) {
+        var data = resp.data || {}
+        self.detail = data
+        self.members = data.members || []
+      }).catch(function(err) {
+        uni.showToast({ title: (err && err.message) || '加载失败', icon: 'none' })
+      })
+    },
+    statusLabel(status) {
+      var map = { pending: '待支付', paid: '已支付', closed: '已关闭' }
+      return map[status] || status || '未知'
+    },
+    handlePay() {
+      var self = this
+      if (self.paying) return
+      self.paying = true
+      uni.showLoading({ title: '团报支付中…' })
+      payGroupOrder({ id: self.id }).then(function(resp) {
+        var data = resp.data || {}
+        uni.hideLoading()
+        if (data.status === 'paid') {
+          uni.showToast({ title: '支付成功', icon: 'success' })
+          setTimeout(function() {
+            self.loadDetail()
+          }, 1000)
+        } else {
+          uni.showToast({ title: '订单已创建,请稍候查询', icon: 'none' })
+        }
+      }).catch(function(err) {
+        uni.hideLoading()
+        uni.showToast({ title: (err && err.message) || '支付失败', icon: 'none' })
+      }).finally(function() {
+        self.paying = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.form-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
+.section-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; }
+.section-title { display: block; font-size: 32rpx; font-weight: 700; color: #1E293B; margin-bottom: 16rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
+.info-row { display: flex; align-items: center; justify-content: space-between; padding: 14rpx 0; border-bottom: 1rpx solid #F8FAFC; }
+.info-label { font-size: 26rpx; color: #64748B; }
+.info-value { font-size: 28rpx; color: #1E293B; font-weight: 500; }
+.info-value.amount { color: #F97316; font-weight: 700; font-size: 32rpx; }
+.info-value.pending { color: #F97316; }
+.info-value.paid { color: #22C55E; }
+.info-value.closed { color: #94A3B8; }
+.pay-area { margin-top: 32rpx; }
+.pay-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: #F97316; color: #FFF; font-size: 30rpx; font-weight: 600; border-radius: 44rpx; border: none; }
+.pay-btn:active { opacity: 0.85; }
+.pay-tip { font-size: 24rpx; color: #94A3B8; text-align: center; margin-top: 16rpx; }
+.paid-tip { font-size: 26rpx; color: #22C55E; text-align: center; padding: 16rpx 0; }
+.member-item { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 0; border-bottom: 1rpx solid #F1F5F9; }
+.member-item:last-child { border-bottom: none; }
+.member-info { display: flex; align-items: center; }
+.member-name { font-size: 28rpx; color: #1E293B; font-weight: 500; margin-right: 24rpx; }
+.member-phone { font-size: 26rpx; color: #64748B; }
+.member-status { font-size: 24rpx; font-weight: 600; }
+.member-status.paid { color: #22C55E; }
+.member-status.pending { color: #F97316; }
+</style>

+ 89 - 0
train-frontend/pages/group-order/index.vue

@@ -0,0 +1,89 @@
+<template>
+  <view class="gorder-page">
+    <view class="section-card">
+      <text class="section-title">团报</text>
+      <text class="card-desc">多人拼单同报一个班次,可填写企业开票信息</text>
+      <button class="create-btn" @click="goCreate">发起团报</button>
+    </view>
+
+    <view class="section-card">
+      <text class="section-title">我的团报单</text>
+      <view v-if="loading" class="empty-tip">加载中…</view>
+      <view v-else-if="list.length === 0" class="empty-tip">暂无团报单</view>
+      <view v-else>
+        <view class="order-item" v-for="item in list" :key="item.id" @click="goDetail(item.id)">
+          <view class="order-head">
+            <text class="order-no">{{ item.groupNo }}</text>
+            <text class="order-status" :class="item.status">{{ statusLabel(item.status) }}</text>
+          </view>
+          <text class="order-class">{{ item.className || ('班次' + item.classId) }}</text>
+          <text class="order-meta">{{ item.memberCount }} 人 · 合计 ¥{{ fenToYuan(item.totalAmount) }}</text>
+          <text class="order-time" v-if="item.createdAt">创建时间:{{ formatDateTime(item.createdAt) }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMyGroupOrders } from '@/utils/api.js'
+import { fenToYuan, formatDateTime } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false
+    }
+  },
+  onShow() {
+    this.loadMine()
+  },
+  methods: {
+    fenToYuan: fenToYuan,
+    formatDateTime: formatDateTime,
+    loadMine() {
+      var self = this
+      self.loading = true
+      getMyGroupOrders().then(function(resp) {
+        self.list = resp.data || []
+      }).catch(function() {
+        uni.showToast({ title: '加载团报单失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    statusLabel(status) {
+      var map = { pending: '待支付', paid: '已支付', closed: '已关闭' }
+      return map[status] || status || '未知'
+    },
+    goCreate() {
+      uni.navigateTo({ url: '/pages/group-order/create' })
+    },
+    goDetail(id) {
+      uni.navigateTo({ url: '/pages/group-order/detail?id=' + id })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.gorder-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
+.section-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; }
+.section-title { display: block; font-size: 32rpx; font-weight: 700; color: #1E293B; margin-bottom: 8rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
+.card-desc { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 24rpx; }
+.empty-tip { font-size: 26rpx; color: #94A3B8; padding: 32rpx 0; text-align: center; }
+.create-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: #F97316; color: #FFF; font-size: 30rpx; font-weight: 600; border-radius: 44rpx; border: none; }
+.create-btn:active { opacity: 0.85; }
+.order-item { padding: 24rpx 0; border-bottom: 1rpx solid #F1F5F9; }
+.order-item:last-child { border-bottom: none; }
+.order-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }
+.order-no { font-size: 26rpx; color: #475569; font-weight: 500; }
+.order-status { font-size: 24rpx; font-weight: 600; }
+.order-status.pending { color: #F97316; }
+.order-status.paid { color: #22C55E; }
+.order-status.closed { color: #94A3B8; }
+.order-class { display: block; font-size: 28rpx; color: #1E293B; font-weight: 600; margin-bottom: 6rpx; }
+.order-meta { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 6rpx; }
+.order-time { display: block; font-size: 24rpx; color: #94A3B8; }
+</style>

+ 10 - 0
train-frontend/pages/mine/index.vue

@@ -49,6 +49,16 @@
         <text class="menu-text">报名中心</text>
         <text class="menu-arrow">›</text>
       </view>
+      <view class="menu-item" @click="goTo('/pages/group-order/index')">
+        <text class="menu-icon">👨‍👩‍👧‍👦</text>
+        <text class="menu-text">团报</text>
+        <text class="menu-arrow">›</text>
+      </view>
+      <view class="menu-item" @click="goTo('/pages/case/index')">
+        <text class="menu-icon">📋</text>
+        <text class="menu-text">案例授权</text>
+        <text class="menu-arrow">›</text>
+      </view>
       <view class="menu-item" @click="goTo('/pages/share/index')">
         <text class="menu-icon">🚀</text>
         <text class="menu-text">分享中心</text>

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

@@ -234,6 +234,28 @@ export const getInviteStats = () => {
   return request('/api/invite/stats', 'POST')
 }
 
+// 团报(FR-PAY-03)
+export const createGroupOrder = (data) => {
+  return request('/api/group-order/create', 'POST', data)
+}
+export const getMyGroupOrders = () => {
+  return request('/api/group-order/mine', 'POST')
+}
+export const getGroupOrderDetail = (data) => {
+  return request('/api/group-order/detail', 'POST', data)
+}
+export const payGroupOrder = (data) => {
+  return request('/api/group-order/pay', 'POST', data)
+}
+
+// 案例授权(FR-FISS-04)
+export const getMyCases = () => {
+  return request('/api/case/mine', 'POST')
+}
+export const decideCase = (data) => {
+  return request('/api/case/decide', 'POST', data)
+}
+
 // 课前资料
 export const getClassMaterials = () => {
   return request('/api/class/materials', 'POST')

+ 13 - 0
train-web/src/api/case.js

@@ -0,0 +1,13 @@
+import request from '@/utils/request'
+
+export function caseCreate(data) {
+  return request.post('/api/admin/case/create', data || {})
+}
+
+export function caseList(data) {
+  return request.post('/api/admin/case/list', data || {})
+}
+
+export function casePublish(data) {
+  return request.post('/api/admin/case/publish', data || {})
+}

+ 9 - 0
train-web/src/api/groupOrder.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+export function groupOrderList(data) {
+  return request.post('/api/admin/group-order/list', data || {})
+}
+
+export function groupOrderMembers(data) {
+  return request.post('/api/admin/group-order/members', data || {})
+}

+ 2 - 0
train-web/src/config/menu-roles.js

@@ -6,6 +6,8 @@ const MENU_ROLES = {
   '/classes': ['admin'],
   '/enrollments': ['admin'],
   '/orders': ['admin'],
+  '/group-orders': ['admin'],
+  '/cases': ['admin'],
   '/invites': ['admin'],
   '/materials': ['admin'],
   '/groups': ['admin'],

+ 12 - 0
train-web/src/router/index.js

@@ -47,6 +47,18 @@ const routes = [
         component: () => import('@/views/Orders.vue'),
         meta: { title: '订单管理' }
       },
+      {
+        path: 'group-orders',
+        name: 'GroupOrders',
+        component: () => import('@/views/GroupOrders.vue'),
+        meta: { title: '团报管理' }
+      },
+      {
+        path: 'cases',
+        name: 'Cases',
+        component: () => import('@/views/Cases.vue'),
+        meta: { title: '案例管理' }
+      },
       {
         path: 'groups',
         name: 'Groups',

+ 226 - 0
train-web/src/views/Cases.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <h2 class="page-title">案例管理</h2>
+      <div class="filter-bar">
+        <el-select v-model="filters.status" placeholder="状态" clearable style="width:140px" @change="fetchList">
+          <el-option label="待授权" value="pending" />
+          <el-option label="已授权" value="authorized" />
+          <el-option label="已拒绝" value="rejected" />
+          <el-option label="已发布" value="published" />
+        </el-select>
+        <el-button type="primary" icon="el-icon-plus" @click="openCreate">采集案例</el-button>
+        <el-button type="primary" icon="el-icon-refresh" plain @click="fetchList">刷新</el-button>
+      </div>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe style="width:100%">
+      <el-table-column prop="id" label="ID" width="70" />
+      <el-table-column prop="userName" label="学员" width="110" />
+      <el-table-column prop="className" label="班次" min-width="130" />
+      <el-table-column prop="title" label="案例标题" min-width="160" />
+      <el-table-column prop="summary" label="内容摘要" min-width="240" show-overflow-tooltip />
+      <el-table-column label="状态" width="100">
+        <template slot-scope="scope">
+          <el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="consentAt" label="授权时间" width="170" :formatter="formatTime" />
+      <el-table-column prop="createdAt" label="创建时间" width="170" :formatter="formatTime" />
+      <el-table-column label="操作" width="130" fixed="right">
+        <template slot-scope="scope">
+          <el-button v-if="scope.row.status === 'authorized'" type="success" size="mini" @click="publishCase(scope.row)">发布</el-button>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 采集案例对话框 -->
+    <el-dialog title="采集案例" :visible.sync="createVisible" width="560px" :close-on-click-modal="false">
+      <el-form label-width="90px">
+        <el-form-item label="学员" required>
+          <el-select v-model="form.uid" placeholder="选择学员" filterable style="width:100%">
+            <el-option v-for="u in users" :key="u.id" :label="u.name + '(' + u.phone + ')'" :value="u.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="班次">
+          <el-select v-model="form.classId" placeholder="选择班次" clearable filterable style="width:100%">
+            <el-option v-for="c in classes" :key="c.id" :label="c.name" :value="c.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="案例标题" required>
+          <el-input v-model="form.title" placeholder="如:XX企业的 AI 智能化改造实践" maxlength="100" />
+        </el-form-item>
+        <el-form-item label="内容摘要" required>
+          <el-input v-model="form.summary" type="textarea" :rows="5" placeholder="脱敏后的案例内容(不包含真实数据/隐私)" maxlength="1000" />
+        </el-form-item>
+        <el-form-item label="来源成果">
+          <el-input v-model="form.sourceSubmissionId" placeholder="可选:成果提交单ID" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="createVisible = false">取消</el-button>
+        <el-button type="primary" :loading="creating" @click="confirmCreate">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { caseList, caseCreate, casePublish } from '@/api/case'
+import { userList } from '@/api/user'
+import { classList } from '@/api/class'
+
+export default {
+  name: 'Cases',
+  data: function () {
+    return {
+      list: [],
+      loading: false,
+      filters: {
+        status: ''
+      },
+      users: [],
+      classes: [],
+      createVisible: false,
+      creating: false,
+      form: {
+        uid: null,
+        classId: null,
+        title: '',
+        summary: '',
+        sourceSubmissionId: ''
+      }
+    }
+  },
+  mounted: function () {
+    this.fetchList()
+    this.loadOptions()
+  },
+  methods: {
+    fenToYuan: function (fen) {
+      var n = Number(fen)
+      if (isNaN(n)) return '0.00'
+      return (n / 100).toFixed(2)
+    },
+    formatTime: function (row, col, val) {
+      if (!val) return '-'
+      return String(val).replace('T', ' ').substring(0, 19)
+    },
+    statusType: function (status) {
+      var map = { pending: 'warning', authorized: 'success', rejected: 'danger', published: 'primary' }
+      return map[status] || 'info'
+    },
+    statusLabel: function (status) {
+      var map = { pending: '待授权', authorized: '已授权', rejected: '已拒绝', published: '已发布' }
+      return map[status] || status || '-'
+    },
+    loadOptions: function () {
+      var self = this
+      userList({}).then(function (res) {
+        self.users = res.data || []
+      }).catch(function () {})
+      classList().then(function (res) {
+        self.classes = res.data || []
+      }).catch(function () {})
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      var payload = {}
+      if (self.filters.status) {
+        payload.status = self.filters.status
+      }
+      caseList(payload).then(function (res) {
+        self.list = res.data || []
+      }).catch(function () {
+        self.$message.error('获取案例列表失败')
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    openCreate: function () {
+      this.form = {
+        uid: null,
+        classId: null,
+        title: '',
+        summary: '',
+        sourceSubmissionId: ''
+      }
+      this.loadOptions()
+      this.createVisible = true
+    },
+    confirmCreate: function () {
+      var self = this
+      if (!self.form.uid) {
+        self.$message.warning('请选择学员')
+        return
+      }
+      if (!self.form.title.trim() || !self.form.summary.trim()) {
+        self.$message.warning('请填写案例标题与内容')
+        return
+      }
+      self.creating = true
+      var data = {
+        uid: self.form.uid,
+        classId: self.form.classId || null,
+        title: self.form.title.trim(),
+        summary: self.form.summary.trim(),
+        sourceSubmissionId: self.form.sourceSubmissionId ? Number(self.form.sourceSubmissionId) : null
+      }
+      caseCreate(data).then(function () {
+        self.$message.success('案例已采集,待学员授权')
+        self.createVisible = false
+        self.fetchList()
+      }).catch(function () {
+        self.$message.error('采集失败')
+      }).finally(function () {
+        self.creating = false
+      })
+    },
+    publishCase: function (row) {
+      var self = this
+      self.$confirm('确定发布该案例吗?发布后学员不可再变更授权。', '提示', {
+        confirmButtonText: '发布',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(function () {
+        casePublish({ caseId: row.id }).then(function () {
+          self.$message.success('发布成功')
+          self.fetchList()
+        }).catch(function () {
+          self.$message.error('发布失败')
+        })
+      }).catch(function () {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.page-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+
+.filter-bar {
+  display: flex;
+  align-items: center;
+}
+
+.filter-bar .el-select {
+  margin-right: 12px;
+}
+
+.filter-bar .el-button {
+  margin-left: 8px;
+}
+</style>

+ 170 - 0
train-web/src/views/GroupOrders.vue

@@ -0,0 +1,170 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <h2 class="page-title">团报管理</h2>
+      <div class="filter-bar">
+        <el-select v-model="filters.status" placeholder="状态" clearable style="width:140px" @change="fetchList">
+          <el-option label="待支付" value="pending" />
+          <el-option label="已支付" value="paid" />
+          <el-option label="已关闭" value="closed" />
+        </el-select>
+        <el-button type="primary" icon="el-icon-refresh" @click="fetchList">刷新</el-button>
+      </div>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe style="width:100%">
+      <el-table-column prop="groupNo" label="团报单号" min-width="180" />
+      <el-table-column prop="className" label="班次" min-width="130" />
+      <el-table-column prop="leaderName" label="团长" width="100" />
+      <el-table-column prop="memberCount" label="人数" width="70" />
+      <el-table-column label="单价" width="90">
+        <template slot-scope="scope">
+          <span>¥{{ fenToYuan(scope.row.unitAmount) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="总金额" width="100">
+        <template slot-scope="scope">
+          <span>¥{{ fenToYuan(scope.row.totalAmount) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="100">
+        <template slot-scope="scope">
+          <el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="invoiceTitle" label="发票抬头" min-width="150">
+        <template slot-scope="scope">
+          <span v-if="scope.row.invoiceTitle">{{ scope.row.invoiceTitle }}({{ scope.row.invoiceType }})</span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="payTime" label="支付时间" width="170" :formatter="formatTime" />
+      <el-table-column prop="createdAt" label="创建时间" width="170" :formatter="formatTime" />
+      <el-table-column label="操作" width="130" fixed="right">
+        <template slot-scope="scope">
+          <el-button type="primary" size="mini" @click="openMembers(scope.row)">成员名单</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 成员名单对话框 -->
+    <el-dialog title="团报成员名单" :visible.sync="membersVisible" width="560px" :close-on-click-modal="false">
+      <el-table :data="members" border stripe size="small" style="width:100%">
+        <el-table-column prop="name" label="姓名" width="120" />
+        <el-table-column prop="phone" label="手机号" width="140" />
+        <el-table-column prop="userName" label="关联学员" width="110">
+          <template slot-scope="scope">
+            <span v-if="scope.row.userName">{{ scope.row.userName }}</span>
+            <span v-else>未注册</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="enrollmentId" label="报名单" width="90">
+          <template slot-scope="scope">
+            <span v-if="scope.row.enrollmentId">{{ scope.row.enrollmentId }}</span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="90">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.status === 'paid' ? 'success' : 'warning'" size="mini">
+              {{ scope.row.status === 'paid' ? '已入账' : '待支付' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="membersVisible = false">关闭</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { groupOrderList, groupOrderMembers } from '@/api/groupOrder'
+
+export default {
+  name: 'GroupOrders',
+  data: function () {
+    return {
+      list: [],
+      loading: false,
+      filters: {
+        status: ''
+      },
+      membersVisible: false,
+      members: []
+    }
+  },
+  mounted: function () {
+    this.fetchList()
+  },
+  methods: {
+    fenToYuan: function (fen) {
+      var n = Number(fen)
+      if (isNaN(n)) return '0.00'
+      return (n / 100).toFixed(2)
+    },
+    formatTime: function (row, col, val) {
+      if (!val) return '-'
+      return String(val).replace('T', ' ').substring(0, 19)
+    },
+    statusType: function (status) {
+      var map = { pending: 'warning', paid: 'success', closed: 'info' }
+      return map[status] || 'info'
+    },
+    statusLabel: function (status) {
+      var map = { pending: '待支付', paid: '已支付', closed: '已关闭' }
+      return map[status] || status || '-'
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      var payload = {}
+      if (self.filters.status) {
+        payload.status = self.filters.status
+      }
+      groupOrderList(payload).then(function (res) {
+        self.list = res.data || []
+      }).catch(function () {
+        self.$message.error('获取团报列表失败')
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    openMembers: function (row) {
+      var self = this
+      self.members = []
+      self.membersVisible = true
+      groupOrderMembers({ id: row.id }).then(function (res) {
+        self.members = res.data || []
+      }).catch(function () {
+        self.$message.error('获取成员名单失败')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.page-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+
+.filter-bar {
+  display: flex;
+  align-items: center;
+}
+
+.filter-bar .el-select {
+  margin-right: 12px;
+}
+</style>

+ 8 - 0
train-web/src/views/Layout.vue

@@ -31,6 +31,14 @@
             <i class="el-icon-wallet"></i>
             <span slot="title">订单管理</span>
           </el-menu-item>
+          <el-menu-item index="/group-orders" v-if="canSee('/group-orders')">
+            <i class="el-icon-shopping-cart-2"></i>
+            <span slot="title">团报管理</span>
+          </el-menu-item>
+          <el-menu-item index="/cases" v-if="canSee('/cases')">
+            <i class="el-icon-collection"></i>
+            <span slot="title">案例管理</span>
+          </el-menu-item>
           <el-menu-item index="/invites" v-if="canSee('/invites')">
             <i class="el-icon-share"></i>
             <span slot="title">转介绍漏斗</span>