Parcourir la source

Phase4: 沙龙产品化与浠艾福活动打通

liaoxg il y a 1 semaine
Parent
commit
1efa4cc

+ 45 - 0
train-backend/src/main/java/com/train/cfc/entity/CfcActivityRegistration.java

@@ -0,0 +1,45 @@
+package com.train.cfc.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.time.LocalDateTime;
+
+/**
+ * cfc 活动报名记录(activity_registrations 表,位于 cfc 同库 zxyj)。
+ * 字段与 E:\cfc 的 ActivityRegistration 实体对齐(LocalDateTime 类型一致)。
+ */
+@Data
+@TableName("activity_registrations")
+public class CfcActivityRegistration implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long activityId;
+
+    private Long childId;
+
+    private Long userId;
+
+    /** 报名人姓名(手动输入时必填,从家庭成员选择时自动填入) */
+    private String registrantName;
+
+    /** 报名人联系方式 */
+    private String registrantPhone;
+
+    private String status;
+
+    private LocalDateTime registeredAt;
+
+    private LocalDateTime approvedAt;
+
+    private Long approvedBy;
+
+    private LocalDateTime createdAt;
+
+    private LocalDateTime updatedAt;
+}

+ 11 - 0
train-backend/src/main/java/com/train/cfc/mapper/CfcActivityRegistrationMapper.java

@@ -0,0 +1,11 @@
+package com.train.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.train.cfc.entity.CfcActivityRegistration;
+
+/**
+ * cfc 活动报名记录(activity_registrations 表)Mapper。
+ * 训练营数据已统一合并进 cfc 同库,走默认数据源即可。
+ */
+public interface CfcActivityRegistrationMapper extends BaseMapper<CfcActivityRegistration> {
+}

+ 224 - 0
train-backend/src/main/java/com/train/controller/SalonController.java

@@ -0,0 +1,224 @@
+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.TrainEnrollment;
+import com.train.entity.TrainInvite;
+import com.train.entity.TrainOrder;
+import com.train.entity.TrainSalon;
+import com.train.entity.TrainUser;
+import com.train.mapper.TrainEnrollmentMapper;
+import com.train.mapper.TrainInviteMapper;
+import com.train.mapper.TrainOrderMapper;
+import com.train.mapper.TrainSalonMapper;
+import com.train.mapper.TrainUserMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+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.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "沙龙", description = "沙龙期次列表、沙龙报名、我的沙龙报名")
+@RestController
+@RequestMapping("/api/salon")
+public class SalonController {
+
+    @Resource
+    private TrainSalonMapper trainSalonMapper;
+    @Resource
+    private TrainEnrollmentMapper trainEnrollmentMapper;
+    @Resource
+    private TrainInviteMapper trainInviteMapper;
+    @Resource
+    private TrainUserMapper trainUserMapper;
+    @Resource
+    private CfcActivityMapper cfcActivityMapper;
+    @Resource
+    private TrainOrderMapper trainOrderMapper;
+
+    /** 占用名额的报名状态 */
+    private static final List<String> OCCUPY_STATUS = Arrays.asList("pending", "paid", "confirmed");
+
+    /**
+     * 可报名沙龙期次列表(含剩余名额/价格)
+     */
+    @Operation(summary = "可报名沙龙期次列表")
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list(@RequestBody(required = false) Map<String, Object> body) {
+        List<TrainSalon> salons = trainSalonMapper.selectList(
+                new LambdaQueryWrapper<TrainSalon>()
+                        .eq(TrainSalon::getStatus, "active")
+                        .orderByAsc(TrainSalon::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainSalon salon : salons) {
+            int salonPrice = salon.getPrice() == null ? 0 : salon.getPrice();
+            int price = salonPrice;
+            int memberPrice = price;
+            // 价格来自 cfc activities(分为单位,cfc 同库),未关联活动则用沙龙自身价格
+            if (salon.getActivityId() != null) {
+                CfcActivity activity = cfcActivityMapper.selectById(salon.getActivityId());
+                if (activity != null) {
+                    price = activity.getPrice() == null ? salonPrice : activity.getPrice();
+                    memberPrice = activity.getMemberPrice() == null ? price : activity.getMemberPrice();
+                }
+            }
+            Long occupied = trainEnrollmentMapper.selectCount(
+                    new LambdaQueryWrapper<TrainEnrollment>()
+                            .eq(TrainEnrollment::getSalonId, salon.getId())
+                            .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
+            long num = (salon.getCapacity() == null ? 0 : salon.getCapacity()) - (occupied == null ? 0L : occupied);
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", salon.getId());
+            row.put("theme", salon.getTheme());
+            row.put("title", salon.getTitle());
+            row.put("time", salon.getTime());
+            row.put("place", salon.getPlace());
+            row.put("capacity", salon.getCapacity());
+            row.put("price", price);
+            row.put("memberPrice", memberPrice);
+            row.put("occupied", occupied == null ? 0L : occupied);
+            row.put("num", num);
+            row.put("status", salon.getStatus());
+            row.put("startAt", salon.getStartAt());
+            row.put("createdAt", salon.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * 创建沙龙报名单(带邀请码则记录转介绍关系)
+     */
+    @Operation(summary = "创建沙龙报名单")
+    @PostMapping("/create")
+    public Result<Map<String, Object>> create(@RequestBody Map<String, Object> body,
+                                              @RequestAttribute("userId") Long userId) {
+        if (body.get("salonId") == null) {
+            return Result.error("请选择沙龙期次");
+        }
+        Long salonId = Long.valueOf(body.get("salonId").toString());
+        String name = body.get("name") == null ? "" : body.get("name").toString().trim();
+        String phone = body.get("phone") == null ? "" : body.get("phone").toString().trim();
+        if (!StringUtils.hasText(name) || !StringUtils.hasText(phone)) {
+            return Result.error("请填写姓名和手机号");
+        }
+        TrainSalon salon = trainSalonMapper.selectById(salonId);
+        if (salon == null) {
+            return Result.error("沙龙不存在");
+        }
+        if (!"active".equals(salon.getStatus())) {
+            return Result.error("该沙龙未开放报名");
+        }
+        // 容量校验(并发下允许少量超卖,最终以支付为准)
+        Long occupied = trainEnrollmentMapper.selectCount(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getSalonId, salonId)
+                        .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
+        if (salon.getCapacity() != null && occupied != null && occupied >= salon.getCapacity()) {
+            return Result.error("该沙龙名额已满");
+        }
+        // 防重复报名(同期沙龙已有未支付/在途报名单)
+        Long exists = trainEnrollmentMapper.selectCount(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, userId)
+                        .eq(TrainEnrollment::getSalonId, salonId)
+                        .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
+        if (exists != null && exists > 0) {
+            return Result.error("您已报名该期沙龙");
+        }
+
+        String inviteCode = body.get("inviteCode") == null ? "" : body.get("inviteCode").toString().trim();
+        TrainEnrollment en = new TrainEnrollment();
+        en.setUid(userId);
+        en.setSalonId(salonId);
+        en.setName(name);
+        en.setPhone(phone);
+        en.setSource(StringUtils.hasText(inviteCode) ? "invite" : "scene");
+        en.setInviteCode(StringUtils.hasText(inviteCode) ? inviteCode : null);
+        en.setStatus("pending");
+        trainEnrollmentMapper.insert(en);
+
+        // 转介绍关系(幂等:uk_code_invitee 唯一键兜底)
+        if (StringUtils.hasText(inviteCode)) {
+            TrainUser inviter = trainUserMapper.selectOne(
+                    new LambdaQueryWrapper<TrainUser>()
+                            .eq(TrainUser::getInviteCode, inviteCode)
+                            .last("LIMIT 1"));
+            if (inviter != null && !inviter.getId().equals(userId)) {
+                Long invCount = trainInviteMapper.selectCount(
+                        new LambdaQueryWrapper<TrainInvite>()
+                                .eq(TrainInvite::getInviteCode, inviteCode)
+                                .eq(TrainInvite::getInviteeId, userId));
+                if (invCount == null || invCount == 0) {
+                    TrainInvite inv = new TrainInvite();
+                    inv.setInviterId(inviter.getId());
+                    inv.setInviteeId(userId);
+                    inv.setInviteCode(inviteCode);
+                    inv.setSuccessful(0);
+                    inv.setRewardStatus("pending");
+                    try {
+                        trainInviteMapper.insert(inv);
+                    } catch (Exception ex) {
+                        // 唯一键冲突(并发重复注册)可忽略
+                    }
+                }
+            }
+        }
+
+        Map<String, Object> row = new HashMap<>();
+        row.put("id", en.getId());
+        row.put("salonId", salonId);
+        row.put("theme", salon.getTheme());
+        row.put("title", salon.getTitle());
+        row.put("status", en.getStatus());
+        return Result.success(row);
+    }
+
+    /**
+     * 我的沙龙报名单列表
+     */
+    @Operation(summary = "我的沙龙报名单列表")
+    @PostMapping("/mine")
+    public Result<List<Map<String, Object>>> mine(@RequestAttribute("userId") Long userId) {
+        List<TrainEnrollment> list = trainEnrollmentMapper.selectList(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, userId)
+                        .isNotNull(TrainEnrollment::getSalonId)
+                        .orderByDesc(TrainEnrollment::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainEnrollment e : list) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("enrollmentId", e.getId());
+            row.put("salonId", e.getSalonId());
+            TrainSalon s = e.getSalonId() == null ? null : trainSalonMapper.selectById(e.getSalonId());
+            row.put("theme", s == null ? null : s.getTheme());
+            row.put("title", s == null ? null : s.getTitle());
+            row.put("time", s == null ? null : s.getTime());
+            row.put("place", s == null ? null : s.getPlace());
+            row.put("status", e.getStatus());
+            row.put("createdAt", e.getCreatedAt());
+            // 补关联订单号:未关闭订单(unpaid)用于前端继续支付/取消;paid 也回填便于查询
+            TrainOrder order = trainOrderMapper.selectOne(
+                    new LambdaQueryWrapper<TrainOrder>()
+                            .eq(TrainOrder::getEnrollmentId, e.getId())
+                            .ne(TrainOrder::getStatus, "closed")
+                            .orderByDesc(TrainOrder::getId)
+                            .last("LIMIT 1"));
+            row.put("orderNo", order == null ? null : order.getOrderNo());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+}

+ 220 - 0
train-backend/src/main/java/com/train/controller/admin/AdminSalonController.java

@@ -0,0 +1,220 @@
+package com.train.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.entity.TrainSalon;
+import com.train.entity.TrainEnrollment;
+import com.train.mapper.TrainSalonMapper;
+import com.train.mapper.TrainEnrollmentMapper;
+import com.train.cfc.entity.CfcActivity;
+import com.train.cfc.mapper.CfcActivityMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Tag(name = "管理端-沙龙", description = "沙龙管理")
+@RestController
+@RequestMapping("/api/admin/salon")
+public class AdminSalonController {
+
+    @Resource
+    private TrainSalonMapper trainSalonMapper;
+    @Resource
+    private TrainEnrollmentMapper trainEnrollmentMapper;
+    @Resource
+    private CfcActivityMapper cfcActivityMapper;
+
+    private static final List<String> OCCUPY_STATUS = Arrays.asList("pending", "paid", "confirmed");
+
+    @Operation(summary = "创建沙龙")
+    @PostMapping("/create")
+    public Result<TrainSalon> create(@RequestBody Map<String, Object> body) {
+        if (body.get("title") == null || body.get("title").toString().trim().isEmpty()) {
+            return Result.error("沙龙标题不能为空");
+        }
+        String title = body.get("title").toString().trim();
+        String theme = body.get("theme") == null ? null : body.get("theme").toString();
+        String time = body.get("time") == null ? null : body.get("time").toString();
+        String place = body.get("place") == null ? null : body.get("place").toString();
+        // 容量:默认 20
+        int capacity = 20;
+        if (body.get("capacity") != null) {
+            try {
+                capacity = Integer.parseInt(body.get("capacity").toString());
+            } catch (NumberFormatException e) {
+                capacity = 20;
+            }
+        }
+        // 价格(分):默认 6800
+        int price = 6800;
+        if (body.get("price") != null) {
+            try {
+                price = Integer.valueOf(body.get("price").toString());
+            } catch (NumberFormatException e) {
+                price = 6800;
+            }
+        }
+        // 开始时间:优先 yyyy-MM-dd HH:mm:ss,其次 yyyy-MM-dd,解析失败留空
+        Date startAt = null;
+        if (body.get("startAt") != null && !body.get("startAt").toString().trim().isEmpty()) {
+            String startAtStr = body.get("startAt").toString().trim();
+            try {
+                startAt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(startAtStr);
+            } catch (ParseException e) {
+                try {
+                    startAt = new SimpleDateFormat("yyyy-MM-dd").parse(startAtStr);
+                } catch (ParseException ex) {
+                    startAt = null;
+                }
+            }
+        }
+        TrainSalon salon = new TrainSalon();
+        salon.setTheme(theme);
+        salon.setTitle(title);
+        salon.setTime(time);
+        salon.setPlace(place);
+        salon.setCapacity(capacity);
+        salon.setStatus("active");
+        salon.setPrice(price);
+        salon.setStartAt(startAt);
+        Date now = new Date();
+        salon.setCreatedAt(now);
+        salon.setUpdatedAt(now);
+        trainSalonMapper.insert(salon);
+        // 沙龙 = cfc 活动:在 cfc 同库 activities 表中同步创建一条活动内容
+        try {
+            CfcActivity activity = new CfcActivity();
+            activity.setTitle(title);
+            activity.setDescription("爱伴·AI之旅周末沙龙:" + title);
+            activity.setActivityType("offline");
+            activity.setDimensionCode("wealth,health,growth");
+            activity.setStatus("published");
+            activity.setAuditStatus("approved");
+            activity.setLocation(place);
+            activity.setMaxParticipants(capacity);
+            activity.setPrice(price);
+            activity.setMemberPrice(price);
+            activity.setRequireRegistration(1);
+            activity.setVisibility("restricted");
+            activity.setCheckinPoints(5);
+            activity.setPlatformPoints(10);
+            Date syncNow = new Date();
+            activity.setCreatedAt(syncNow);
+            activity.setUpdatedAt(syncNow);
+            cfcActivityMapper.insert(activity);
+            salon.setActivityId(activity.getId());
+            trainSalonMapper.updateById(salon);
+        } catch (Exception e) {
+            // 同步 cfc 活动失败不阻塞建沙龙,仅记录
+            org.slf4j.LoggerFactory.getLogger(getClass())
+                    .warn("同步 cfc 活动失败: {}", e.getMessage());
+        }
+        return Result.success(salon);
+    }
+
+    @Operation(summary = "沙龙列表")
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list(@RequestBody(required = false) Map<String, Object> body) {
+        List<TrainSalon> salons = trainSalonMapper.selectList(
+                new LambdaQueryWrapper<TrainSalon>().orderByDesc(TrainSalon::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainSalon s : salons) {
+            Long occupied = trainEnrollmentMapper.selectCount(
+                    new LambdaQueryWrapper<TrainEnrollment>()
+                            .eq(TrainEnrollment::getSalonId, s.getId())
+                            .in(TrainEnrollment::getStatus, OCCUPY_STATUS));
+            if (occupied == null) {
+                occupied = 0L;
+            }
+            int salonPrice = s.getPrice() == null ? 0 : s.getPrice();
+            int price;
+            int memberPrice;
+            CfcActivity activity = null;
+            if (s.getActivityId() != null) {
+                activity = cfcActivityMapper.selectById(s.getActivityId());
+            }
+            if (activity == null) {
+                price = salonPrice;
+                memberPrice = salonPrice;
+            } else {
+                price = activity.getPrice() == null ? salonPrice : activity.getPrice();
+                memberPrice = activity.getMemberPrice() == null ? price : activity.getMemberPrice();
+            }
+            int capacity = s.getCapacity() == null ? 0 : s.getCapacity();
+            long num = capacity - occupied;
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", s.getId());
+            row.put("activityId", s.getActivityId());
+            row.put("theme", s.getTheme());
+            row.put("title", s.getTitle());
+            row.put("time", s.getTime());
+            row.put("place", s.getPlace());
+            row.put("capacity", s.getCapacity());
+            row.put("price", price);
+            row.put("memberPrice", memberPrice);
+            row.put("occupied", occupied);
+            row.put("num", num);
+            row.put("status", s.getStatus());
+            row.put("startAt", s.getStartAt());
+            row.put("createdAt", s.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "更新沙龙状态")
+    @PostMapping("/status")
+    public Result<Boolean> status(@RequestBody Map<String, Object> body) {
+        if (body.get("id") == null) {
+            return Result.error("缺少沙龙ID");
+        }
+        Long id = Long.valueOf(body.get("id").toString());
+        if (body.get("status") == null || body.get("status").toString().trim().isEmpty()) {
+            return Result.error("缺少状态");
+        }
+        String status = body.get("status").toString();
+        if (!"draft".equals(status) && !"active".equals(status) && !"finished".equals(status)) {
+            return Result.error("非法状态");
+        }
+        TrainSalon salon = trainSalonMapper.selectById(id);
+        if (salon == null) {
+            return Result.error("沙龙不存在");
+        }
+        salon.setStatus(status);
+        salon.setUpdatedAt(new Date());
+        trainSalonMapper.updateById(salon);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "沙龙报名列表")
+    @PostMapping("/registrations")
+    public Result<List<Map<String, Object>>> registrations(@RequestBody Map<String, Object> body) {
+        if (body.get("salonId") == null) {
+            return Result.error("缺少沙龙ID");
+        }
+        Long salonId = Long.valueOf(body.get("salonId").toString());
+        List<TrainEnrollment> enrollments = trainEnrollmentMapper.selectList(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getSalonId, salonId)
+                        .orderByDesc(TrainEnrollment::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainEnrollment e : enrollments) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", e.getId());
+            row.put("uid", e.getUid());
+            row.put("name", e.getName());
+            row.put("phone", e.getPhone());
+            row.put("source", e.getSource());
+            row.put("inviteCode", e.getInviteCode());
+            row.put("status", e.getStatus());
+            row.put("createdAt", e.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+}

+ 4 - 0
train-backend/src/main/java/com/train/entity/TrainEnrollment.java

@@ -22,6 +22,10 @@ public class TrainEnrollment implements Serializable {
 
     /** 所选班次 ID(train_class.id,该课程下的某一期) */
     private Long classId;
+
+    /** 关联沙龙期次 ID(train_salon.id,为空=课程报名) */
+    private Long salonId;
+
     private String name;
     private String phone;
     private Integer bringLaptop; // 1=带电脑

+ 33 - 0
train-backend/src/main/java/com/train/entity/TrainSalon.java

@@ -0,0 +1,33 @@
+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;
+
+@Data
+@TableName("train_salon")
+public class TrainSalon implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联的 cfc 活动 ID(activities.id,位于 cfc 同库) */
+    private Long activityId;
+
+    /** 主题:ai_basic/wealth/health/growth */
+    private String theme;
+
+    private String title;
+    private String time;
+    private String place;
+    private Integer capacity;
+    private Integer price; // 价格(分),默认 6800
+    private String status; // draft/active/finished
+    private Date startAt;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 9 - 0
train-backend/src/main/java/com/train/mapper/TrainSalonMapper.java

@@ -0,0 +1,9 @@
+package com.train.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.train.entity.TrainSalon;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TrainSalonMapper extends BaseMapper<TrainSalon> {
+}

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

@@ -2,7 +2,9 @@ package com.train.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.cfc.entity.CfcActivity;
+import com.train.cfc.entity.CfcActivityRegistration;
 import com.train.cfc.mapper.CfcActivityMapper;
+import com.train.cfc.mapper.CfcActivityRegistrationMapper;
 import com.train.controller.PlanController;
 import com.train.entity.TrainClass;
 import com.train.entity.TrainCourse;
@@ -11,6 +13,7 @@ import com.train.entity.TrainGroupOrder;
 import com.train.entity.TrainGroupOrderMember;
 import com.train.entity.TrainInvite;
 import com.train.entity.TrainOrder;
+import com.train.entity.TrainSalon;
 import com.train.entity.TrainUser;
 import com.train.mapper.TrainClassMapper;
 import com.train.mapper.TrainCourseMapper;
@@ -19,6 +22,7 @@ import com.train.mapper.TrainGroupOrderMapper;
 import com.train.mapper.TrainGroupOrderMemberMapper;
 import com.train.mapper.TrainInviteMapper;
 import com.train.mapper.TrainOrderMapper;
+import com.train.mapper.TrainSalonMapper;
 import com.train.mapper.TrainUserMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.util.StringUtils;
@@ -28,6 +32,7 @@ import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
@@ -62,6 +67,10 @@ public class PayService {
     @Resource
     private CfcActivityMapper cfcActivityMapper;
     @Resource
+    private TrainSalonMapper trainSalonMapper;
+    @Resource
+    private CfcActivityRegistrationMapper cfcActivityRegistrationMapper;
+    @Resource
     private PlanController planController;
 
     @Value("${wechat.test-mode}")
@@ -122,6 +131,22 @@ public class PayService {
                 memberPrice = course.getMemberPrice() == null ? price : course.getMemberPrice();
             }
         }
+        // 沙龙报名(学/课程链路之外):价格取沙龙关联的 cfc 活动(memberPrice 优先),否则取沙龙自身价格
+        if (activity == null && en.getSalonId() != null) {
+            TrainSalon salon = trainSalonMapper.selectById(en.getSalonId());
+            if (salon != null) {
+                if (salon.getActivityId() != null) {
+                    activity = cfcActivityMapper.selectById(salon.getActivityId());
+                }
+                if (activity != null) {
+                    price = activity.getPrice() == null ? 0 : activity.getPrice();
+                    memberPrice = activity.getMemberPrice() == null ? price : activity.getMemberPrice();
+                } else {
+                    price = salon.getPrice() == null ? 0 : salon.getPrice();
+                    memberPrice = price;
+                }
+            }
+        }
         int amount = memberPrice > 0 ? memberPrice : price;
 
         TrainOrder order = new TrainOrder();
@@ -233,6 +258,14 @@ public class PayService {
                 }
                 // 转介绍联动:被邀请人付费成功 → 更新关系 → 给邀请人发激励卡券
                 rewardInviterIfInvited(en, order);
+                // 沙龙报名支付成功 → 同步报名记录到 cfc activity_registrations(失败不阻塞入账)
+                if (en.getSalonId() != null) {
+                    try {
+                        syncSalonRegistrationToCfc(en);
+                    } catch (Exception e) {
+                        log.warn("同步 cfc 沙龙报名失败: enrollmentId={}, err={}", en.getId(), e.getMessage());
+                    }
+                }
             }
         }
         log.info("支付入账完成: orderNo={}", orderNo);
@@ -278,6 +311,46 @@ public class PayService {
         }
     }
 
+    /**
+     * 沙龙报名同步到 cfc activity_registrations(幂等:activityId+userId 已存在则跳过)。
+     * 学员无 cfc 会员 ID 时仅告警跳过,不阻塞支付入账。
+     */
+    private void syncSalonRegistrationToCfc(TrainEnrollment en) {
+        TrainSalon salon = trainSalonMapper.selectById(en.getSalonId());
+        if (salon == null || salon.getActivityId() == null) {
+            log.warn("沙龙或关联活动缺失,跳过 cfc 报名同步: enrollmentId={}", en.getId());
+            return;
+        }
+        if (en.getUid() == null) {
+            log.warn("报名单无学员ID,跳过 cfc 报名同步: enrollmentId={}", en.getId());
+            return;
+        }
+        TrainUser user = trainUserMapper.selectById(en.getUid());
+        if (user == null || user.getCfcUserId() == null) {
+            log.warn("学员无 cfc 会员ID,跳过 cfc 报名同步: uid={}", en.getUid());
+            return;
+        }
+        Long exist = cfcActivityRegistrationMapper.selectCount(
+                new LambdaQueryWrapper<CfcActivityRegistration>()
+                        .eq(CfcActivityRegistration::getActivityId, salon.getActivityId())
+                        .eq(CfcActivityRegistration::getUserId, user.getCfcUserId()));
+        if (exist != null && exist > 0) {
+            return; // 幂等:已同步过
+        }
+        CfcActivityRegistration reg = new CfcActivityRegistration();
+        reg.setActivityId(salon.getActivityId());
+        reg.setUserId(user.getCfcUserId());
+        reg.setRegistrantName(en.getName());
+        reg.setRegistrantPhone(en.getPhone());
+        reg.setStatus("approved");
+        LocalDateTime now = LocalDateTime.now();
+        reg.setRegisteredAt(now);
+        reg.setApprovedAt(now);
+        cfcActivityRegistrationMapper.insert(reg);
+        log.info("沙龙报名已同步 cfc: enrollmentId={}, registrationId={}, activityId={}",
+                en.getId(), reg.getId(), salon.getActivityId());
+    }
+
     /**
      * 查询订单状态(校验归属)。
      * @return {orderNo, amount, status, payTime, classId}
@@ -347,9 +420,45 @@ public class PayService {
                 trainEnrollmentMapper.updateById(en);
             }
         }
+        // 沙龙报名退款 → cfc 报名记录置 cancelled(失败不阻塞退款)
+        if (order.getEnrollmentId() != null) {
+            TrainEnrollment en = trainEnrollmentMapper.selectById(order.getEnrollmentId());
+            if (en != null && en.getSalonId() != null) {
+                try {
+                    cancelSalonRegistrationToCfc(en);
+                } catch (Exception e) {
+                    log.warn("取消 cfc 沙龙报名失败: enrollmentId={}, err={}", en.getId(), e.getMessage());
+                }
+            }
+        }
         return true;
     }
 
+    /**
+     * 沙龙报名退款时,将 cfc activity_registrations 对应记录置为 cancelled。
+     */
+    private void cancelSalonRegistrationToCfc(TrainEnrollment en) {
+        TrainSalon salon = trainSalonMapper.selectById(en.getSalonId());
+        if (salon == null || salon.getActivityId() == null || en.getUid() == null) {
+            return;
+        }
+        TrainUser user = trainUserMapper.selectById(en.getUid());
+        if (user == null || user.getCfcUserId() == null) {
+            return;
+        }
+        CfcActivityRegistration reg = cfcActivityRegistrationMapper.selectOne(
+                new LambdaQueryWrapper<CfcActivityRegistration>()
+                        .eq(CfcActivityRegistration::getActivityId, salon.getActivityId())
+                        .eq(CfcActivityRegistration::getUserId, user.getCfcUserId())
+                        .last("LIMIT 1"));
+        if (reg != null && !"cancelled".equals(reg.getStatus())) {
+            reg.setStatus("cancelled");
+            reg.setUpdatedAt(LocalDateTime.now());
+            cfcActivityRegistrationMapper.updateById(reg);
+            log.info("沙龙报名已取消同步 cfc: enrollmentId={}, registrationId={}", en.getId(), reg.getId());
+        }
+    }
+
     /**
      * 关闭超时未支付订单(定时任务用)。
      */

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

@@ -296,6 +296,7 @@ CREATE TABLE IF NOT EXISTS train_enrollment (
     uid BIGINT COMMENT '学员ID(登录后)',
     course_id BIGINT COMMENT '报名所属课程ID(train_course.id,报名=报课程)',
     class_id BIGINT COMMENT '所选班次ID(该课程的某一期)',
+    salon_id BIGINT COMMENT '关联沙龙期次(train_salon.id)',
     name VARCHAR(50) COMMENT '姓名',
     phone VARCHAR(11) COMMENT '手机号',
     bring_laptop TINYINT DEFAULT 0 COMMENT '1=带电脑',
@@ -445,3 +446,24 @@ CREATE TABLE IF NOT EXISTS train_case (
     INDEX idx_uid (uid),
     INDEX idx_status (status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学员案例(采集/授权/回流)';
+
+-- 沙龙表(沙龙产品化:一期沙龙 = train_salon 行 + 同步一条 cfc 活动)
+CREATE TABLE IF NOT EXISTS train_salon (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    activity_id BIGINT COMMENT '关联 cfc 活动 ID(activities.id,cfc 同库)',
+    theme VARCHAR(50) COMMENT '主题: ai_basic/wealth/health/growth',
+    title VARCHAR(100) NOT NULL COMMENT '沙龙标题',
+    time VARCHAR(100) COMMENT '时间描述',
+    place VARCHAR(100) COMMENT '地点',
+    capacity INT DEFAULT 20 COMMENT '容量',
+    price INT DEFAULT 6800 COMMENT '价格(分)',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'draft/active/finished',
+    start_at DATETIME COMMENT '开始时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_status (status),
+    INDEX idx_activity_id (activity_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='沙龙期次';
+
+-- 沙龙报名补列(存量库幂等迁移:重复执行报错被吞,属预期)
+ALTER TABLE train_enrollment ADD COLUMN salon_id BIGINT COMMENT '关联沙龙期次(train_salon.id)';

+ 12 - 0
train-frontend/pages.json

@@ -108,6 +108,18 @@
         "navigationBarTitleText": "报名信息"
       }
     },
+    {
+      "path": "pages/salon/list",
+      "style": {
+        "navigationBarTitleText": "沙龙活动"
+      }
+    },
+    {
+      "path": "pages/salon/form",
+      "style": {
+        "navigationBarTitleText": "沙龙报名"
+      }
+    },
     {
       "path": "pages/pay/index",
       "style": {

+ 4 - 0
train-frontend/pages/index/index.vue

@@ -42,6 +42,10 @@
         <text class="nav-icon">🎓</text>
         <text class="nav-text">校友校验</text>
       </view>
+      <view class="nav-item" @click="goTo('/pages/salon/list')">
+        <text class="nav-icon">🎨</text>
+        <text class="nav-text">周末沙龙</text>
+      </view>
     </view>
 
     <view class="section-title">最近课程</view>

+ 121 - 0
train-frontend/pages/salon/form.vue

@@ -0,0 +1,121 @@
+<template>
+  <view class="form-page">
+    <view class="section-card">
+      <text class="section-title">沙龙报名</text>
+      <text class="card-desc">填写联系方式,完成支付后即可锁定名额</text>
+
+      <view class="summary-box" v-if="salonTitle">
+        <view class="summary-row">
+          <text class="summary-label">活动主题</text>
+          <text class="summary-value">{{ salonTheme }} - {{ salonTitle }}</text>
+        </view>
+      </view>
+
+      <view class="form-item">
+        <text class="form-label">姓名</text>
+        <input class="form-input" v-model="form.name" placeholder="请输入真实姓名" />
+      </view>
+      <view class="form-item">
+        <text class="form-label">手机号</text>
+        <input class="form-input" v-model="form.phone" type="number" maxlength="11" placeholder="请输入手机号" />
+      </view>
+      <view class="form-item">
+        <text class="form-label">邀请码(选填)</text>
+        <input class="form-input" v-model="form.inviteCode" placeholder="邀请码(选填)" />
+      </view>
+
+      <button class="submit-btn" @click="handleSubmit" :loading="loading" :disabled="loading">提交报名</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { createSalonEnroll } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
+
+export default {
+  data() {
+    return {
+      form: {
+        salonId: '',
+        name: '',
+        phone: '',
+        inviteCode: ''
+      },
+      salonTheme: '',
+      salonTitle: '',
+      loading: false
+    }
+  },
+  onLoad(options) {
+    if (!guardPage()) return
+    this.form.salonId = options && options.salonId ? options.salonId : ''
+    this.salonTheme = options && options.theme ? decodeURIComponent(options.theme) : ''
+    this.salonTitle = options && options.title ? decodeURIComponent(options.title) : ''
+    this.form.name = options && options.name ? decodeURIComponent(options.name) : ''
+    this.form.phone = options && options.phone ? decodeURIComponent(options.phone) : ''
+    
+    if (!this.form.salonId) {
+      uni.showToast({ title: '参数错误', icon: 'none' })
+      setTimeout(function() {
+        uni.navigateBack({ delta: 1 })
+      }, 1500)
+    }
+  },
+  methods: {
+    handleSubmit() {
+      var name = (this.form.name || '').trim()
+      var phone = (this.form.phone || '').trim()
+      if (!name) {
+        uni.showToast({ title: '请填写姓名', icon: 'none' })
+        return
+      }
+      if (!/^1\d{10}$/.test(phone)) {
+        uni.showToast({ title: '请填写正确的手机号', icon: 'none' })
+        return
+      }
+      if (this.loading) return
+      this.loading = true
+      var self = this
+      var payload = {
+        salonId: self.form.salonId,
+        name: name,
+        phone: phone,
+        inviteCode: (self.form.inviteCode || '').trim()
+      }
+      createSalonEnroll(payload).then(function(resp) {
+        uni.showToast({ title: '报名成功', icon: 'success' })
+        setTimeout(function() {
+          var enrollmentId = resp.data && resp.data.id ? resp.data.id : ''
+          if (enrollmentId) {
+            uni.redirectTo({ url: '/pages/pay/index?enrollmentId=' + enrollmentId })
+          } else {
+            uni.redirectTo({ url: '/pages/salon/list' })
+          }
+        }, 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; }
+.summary-box { background: #FFF7ED; border-radius: 8rpx; padding: 20rpx 24rpx; margin-bottom: 24rpx; border: 2rpx solid #FED7AA; }
+.summary-row { display: flex; align-items: center; margin-bottom: 8rpx; }
+.summary-row:last-child { margin-bottom: 0; }
+.summary-label { font-size: 24rpx; color: #64748B; width: 140rpx; flex-shrink: 0; }
+.summary-value { font-size: 26rpx; color: #1E293B; font-weight: 600; flex: 1; }
+.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; }
+.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>

+ 129 - 0
train-frontend/pages/salon/list.vue

@@ -0,0 +1,129 @@
+<template>
+  <view class="salon-page">
+    <view class="section-card">
+      <text class="section-title">周末沙龙</text>
+      <text class="card-desc">探索AI前沿,共创成长空间</text>
+      
+      <view v-if="loading" class="empty-tip">加载中…</view>
+      <view v-else-if="salons.length === 0" class="empty-tip">暂无活动沙龙</view>
+      <view v-else>
+        <view class="salon-card" v-for="item in salons" :key="item.id">
+          <view class="salon-head">
+            <text class="salon-theme">{{ mapTheme(item.theme) }}</text>
+            <text class="salon-title">{{ item.title }}</text>
+          </view>
+          
+          <view class="salon-info">
+            <view class="info-item">
+              <text class="info-label">时间:</text>
+              <text class="info-value">{{ formatDateTime(item.time) }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">地点:</text>
+              <text class="info-value">{{ item.place }}</text>
+            </view>
+            <view class="info-item">
+              <text class="info-label">余位:</text>
+              <text class="info-value">{{ item.num }} / {{ item.capacity }}</text>
+            </view>
+          </view>
+          
+          <view class="salon-price-box">
+            <text class="salon-price" v-if="item.price > 0">¥{{ fenToYuan(item.price) }}</text>
+            <text class="salon-price-free" v-else>免费</text>
+            <text class="member-price" v-if="item.memberPrice > 0 && item.memberPrice < item.price">
+              会员价 ¥{{ fenToYuan(item.memberPrice) }}
+            </text>
+          </view>
+          
+          <view class="salon-foot">
+            <button 
+              class="enroll-btn" 
+              :disabled="item.num <= 0" 
+              @click="handleEnrollClick(item)"
+            >
+              {{ item.num <= 0 ? '已满' : '立即报名' }}
+            </button>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getSalonList } from '@/utils/api.js'
+import { fenToYuan, formatDateTime } from '@/utils/format.js'
+import { guardPage } from '@/utils/guard.js'
+
+export default {
+  data() {
+    return {
+      salons: [],
+      loading: false,
+      themeMap: {
+        ai_basic: 'AI 认知基础',
+        wealth: '家庭财富管理',
+        health: '健康管理',
+        growth: '孩子成长陪伴'
+      }
+    }
+  },
+  onShow() {
+    if (!guardPage()) return
+    this.loadSalons()
+  },
+  methods: {
+    fenToYuan: fenToYuan,
+    formatDateTime: formatDateTime,
+    mapTheme(theme) {
+      return this.themeMap[theme] || theme || '通用主题'
+    },
+    loadSalons() {
+      var self = this
+      self.loading = true
+      getSalonList().then(function(resp) {
+        self.salons = resp.data || []
+      }).catch(function() {
+        uni.showToast({ title: '加载沙龙失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    handleEnrollClick(item) {
+      var userInfo = uni.getStorageSync('userInfo') || {}
+      uni.navigateTo({ 
+        url: '/pages/salon/form?salonId=' + item.id + 
+             '&theme=' + encodeURIComponent(this.mapTheme(item.theme)) + 
+             '&title=' + encodeURIComponent(item.title) + 
+             '&name=' + encodeURIComponent(userInfo.name || '') + 
+             '&phone=' + encodeURIComponent(userInfo.phone || '') 
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.salon-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; }
+.salon-card { border: 2rpx solid #E2E8F0; border-radius: 12rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.salon-head { margin-bottom: 16rpx; }
+.salon-theme { display: block; background: #FFF7ED; color: #F97316; font-size: 22rpx; font-weight: 600; padding: 4rpx 12rpx; border-radius: 4rpx; width: fit-content; margin-bottom: 8rpx; }
+.salon-title { display: block; font-size: 30rpx; font-weight: 600; color: #1E293B; }
+.salon-info { margin-bottom: 16rpx; }
+.info-item { display: flex; align-items: center; margin-bottom: 4rpx; }
+.info-label { font-size: 24rpx; color: #64748B; width: 70rpx; flex-shrink: 0; }
+.info-value { font-size: 26rpx; color: #475569; }
+.salon-price-box { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
+.salon-price { font-size: 28rpx; color: #F97316; font-weight: 600; }
+.salon-price-free { font-size: 28rpx; color: #22C55E; font-weight: 600; }
+.member-price { font-size: 24rpx; color: #64748B; }
+.salon-foot { display: flex; align-items: center; justify-content: flex-end; }
+.enroll-btn { width: 200rpx; height: 64rpx; line-height: 64rpx; font-size: 26rpx; background: #F97316; color: #FFF; border-radius: 32rpx; border: none; margin: 0; }
+.enroll-btn:active { opacity: 0.85; }
+.enroll-btn:disabled { background: #CBD5E1; }
+</style>

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

@@ -229,6 +229,17 @@ export const getMyEnrollments = () => {
   return request('/api/enroll/mine', 'POST')
 }
 
+// 沙龙
+export const getSalonList = () => {
+  return request('/api/salon/list', 'POST')
+}
+export const createSalonEnroll = (data) => {
+  return request('/api/salon/create', 'POST', data)
+}
+export const getMySalons = () => {
+  return request('/api/salon/mine', 'POST')
+}
+
 // 支付
 export const createPayOrder = (data) => {
   return request('/api/pay/create', 'POST', data)

+ 17 - 0
train-web/src/api/salon.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function createSalon(data) {
+  return request.post('/api/admin/salon/create', data)
+}
+
+export function salonList() {
+  return request.post('/api/admin/salon/list', {})
+}
+
+export function salonStatus(data) {
+  return request.post('/api/admin/salon/status', data)
+}
+
+export function salonRegistrations(data) {
+  return request.post('/api/admin/salon/registrations', data)
+}

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

@@ -41,6 +41,12 @@ const routes = [
         component: () => import('@/views/Classes.vue'),
         meta: { title: '班级管理' }
       },
+      {
+        path: 'salons',
+        name: 'Salons',
+        component: () => import('@/views/Salons.vue'),
+        meta: { title: '沙龙管理' }
+      },
       {
         path: 'enrollments',
         name: 'Enrollments',

+ 239 - 0
train-web/src/views/Salons.vue

@@ -0,0 +1,239 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <h2 class="page-title">沙龙管理</h2>
+      <el-button type="primary" icon="el-icon-plus" @click="showCreateDialog = true">新建沙龙</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 label="主题" width="120">
+        <template slot-scope="{ row }">{{ themeMap[row.theme] || row.theme || '-' }}</template>
+      </el-table-column>
+      <el-table-column prop="title" label="标题" min-width="160" />
+      <el-table-column prop="time" label="时间" width="160" />
+      <el-table-column prop="place" label="地点" width="160" />
+      <el-table-column prop="capacity" label="容量" width="80" />
+      <el-table-column label="报名情况" width="120">
+        <template slot-scope="{ row }">{{ row.occupied }} / {{ row.num }}</template>
+      </el-table-column>
+      <el-table-column label="价格" width="100">
+        <template slot-scope="{ row }">{{ fenToYuan(row.price) }} 元</template>
+      </el-table-column>
+      <el-table-column label="状态" width="100">
+        <template slot-scope="{ row }">
+          <el-tag :type="statusTagType(row.status)">{{ statusMap[row.status] || row.status }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="startAt" label="开始时间" width="170" :formatter="formatTime" />
+      <el-table-column prop="createdAt" label="创建时间" width="170" :formatter="formatTime" />
+      <el-table-column label="操作" width="200">
+        <template slot-scope="{ row }">
+          <el-button size="mini" @click="handleToggleStatus(row)">
+            {{ row.status === 'active' ? '结束' : '重新上架' }}
+          </el-button>
+          <el-button size="mini" type="text" @click="viewRegistrations(row)">报名名单</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 新建沙龙 Dialog -->
+    <el-dialog title="新建沙龙" :visible.sync="showCreateDialog" width="500px" @close="resetCreateForm">
+      <el-form :model="createForm" :rules="createRules" ref="createForm" label-width="80px">
+        <el-form-item label="主题" prop="theme">
+          <el-select v-model="createForm.theme" placeholder="请选择主题" style="width:100%">
+            <el-option v-for="(val, key) in themeMap" :key="key" :label="val" :value="key" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="createForm.title" placeholder="请输入沙龙标题" />
+        </el-form-item>
+        <el-form-item label="时间">
+          <el-input v-model="createForm.time" placeholder="如:周六10:00-17:00" />
+        </el-form-item>
+        <el-form-item label="地点">
+          <el-input v-model="createForm.place" placeholder="如:北京会议室A" />
+        </el-form-item>
+        <el-form-item label="容量" prop="capacity">
+          <el-input-number v-model="createForm.capacity" :min="1" :max="9999" />
+        </el-form-item>
+        <el-form-item label="价格(元)">
+          <el-input-number v-model="createForm.price" :min="0" :precision="2" />
+        </el-form-item>
+        <el-form-item label="开始时间">
+          <el-date-picker 
+            v-model="createForm.startAt" 
+            type="datetime" 
+            placeholder="选择开始时间" 
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width:100%"
+          />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="showCreateDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleCreate" :loading="submitting">确定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 报名名单 Dialog -->
+    <el-dialog title="报名名单" :visible.sync="showRegDialog" width="800px">
+      <el-table :data="registrations" v-loading="regLoading" border stripe>
+        <el-table-column prop="name" label="姓名" width="100" />
+        <el-table-column prop="phone" label="手机" width="120" />
+        <el-table-column prop="source" label="来源" width="120" />
+        <el-table-column prop="inviteCode" label="邀请码" width="120" />
+        <el-table-column label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag size="mini">{{ row.status }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="报名时间" width="170" :formatter="formatTime" />
+      </el-table>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { salonList, createSalon, salonStatus, salonRegistrations } from '@/api/salon'
+
+export default {
+  name: 'Salons',
+  data: function () {
+    return {
+      list: [],
+      loading: false,
+      submitting: false,
+      showCreateDialog: false,
+      showRegDialog: false,
+      regLoading: false,
+      registrations: [],
+      themeMap: {
+        ai_basic: 'AI 认知基础',
+        wealth: '家庭财富管理',
+        health: '健康管理',
+        growth: '孩子成长陪伴'
+      },
+      statusMap: {
+        draft: '草稿',
+        active: '进行中',
+        finished: '已结束'
+      },
+      createForm: {
+        theme: '',
+        title: '',
+        time: '',
+        place: '',
+        capacity: 30,
+        price: 68,
+        startAt: ''
+      },
+      createRules: {
+        theme: [{ required: true, message: '请选择主题', trigger: 'change' }],
+        title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
+        capacity: [{ required: true, message: '请输入容量', trigger: 'blur' }]
+      }
+    }
+  },
+  mounted: function () {
+    this.fetchList()
+  },
+  methods: {
+    formatTime: function (row, col, val) {
+      return val || '-'
+    },
+    fenToYuan: function (fen) {
+      return (fen / 100).toFixed(2)
+    },
+    statusTagType: function (status) {
+      const map = {
+        draft: 'info',
+        active: 'success',
+        finished: 'warning'
+      }
+      return map[status] || ''
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      salonList().then(function (res) {
+        self.list = res.data || []
+      }).catch(function () {
+        self.$message.error('获取沙龙列表失败')
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    resetCreateForm: function () {
+      this.createForm = {
+        theme: '',
+        title: '',
+        time: '',
+        place: '',
+        capacity: 30,
+        price: 68,
+        startAt: ''
+      }
+    },
+    handleCreate: function () {
+      var self = this
+      if (self.submitting) return
+      self.$refs.createForm.validate(function (valid) {
+        if (!valid) return
+        self.submitting = true
+        
+        // Convert price from Yuan to Fen
+        var payload = Object.assign({}, self.createForm)
+        payload.price = Math.round(payload.price * 100)
+        
+        createSalon(payload).then(function () {
+          self.$message.success('创建成功')
+          self.showCreateDialog = false
+          self.fetchList()
+        }).catch(function () {
+          self.$message.error('创建失败')
+        }).finally(function () {
+          self.submitting = false
+        })
+      })
+    },
+    handleToggleStatus: function (row) {
+      var self = this
+      var nextStatus = row.status === 'active' ? 'finished' : 'active'
+      salonStatus({ id: row.id, status: nextStatus }).then(function () {
+        self.$message.success('状态更新成功')
+        self.fetchList()
+      }).catch(function () {
+        self.$message.error('更新失败')
+      })
+    },
+    viewRegistrations: function (row) {
+      var self = this
+      self.showRegDialog = true
+      self.regLoading = true
+      salonRegistrations({ salonId: row.id }).then(function (res) {
+        self.registrations = res.data || []
+      }).catch(function () {
+        self.$message.error('获取报名名单失败')
+      }).finally(function () {
+        self.regLoading = false
+      })
+    }
+  }
+}
+</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;
+}
+</style>