Przeglądaj źródła

沙龙闭环: 现场签到与会后反馈两环节补齐

liaoxg 1 tydzień temu
rodzic
commit
3c3a48a08d

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

@@ -8,10 +8,14 @@ import com.train.entity.TrainEnrollment;
 import com.train.entity.TrainInvite;
 import com.train.entity.TrainOrder;
 import com.train.entity.TrainSalon;
+import com.train.entity.TrainSalonCheckin;
+import com.train.entity.TrainSalonFeedback;
 import com.train.entity.TrainUser;
 import com.train.mapper.TrainEnrollmentMapper;
 import com.train.mapper.TrainInviteMapper;
 import com.train.mapper.TrainOrderMapper;
+import com.train.mapper.TrainSalonCheckinMapper;
+import com.train.mapper.TrainSalonFeedbackMapper;
 import com.train.mapper.TrainSalonMapper;
 import com.train.mapper.TrainUserMapper;
 import io.swagger.v3.oas.annotations.Operation;
@@ -47,10 +51,17 @@ public class SalonController {
     private CfcActivityMapper cfcActivityMapper;
     @Resource
     private TrainOrderMapper trainOrderMapper;
+    @Resource
+    private TrainSalonCheckinMapper trainSalonCheckinMapper;
+    @Resource
+    private TrainSalonFeedbackMapper trainSalonFeedbackMapper;
 
     /** 占用名额的报名状态 */
     private static final List<String> OCCUPY_STATUS = Arrays.asList("pending", "paid", "confirmed");
 
+    /** 可签到/反馈的报名状态(已支付或已确认) */
+    private static final List<String> ACTIVE_STATUS = Arrays.asList("paid", "confirmed");
+
     /**
      * 可报名沙龙期次列表(含剩余名额/价格)
      */
@@ -217,8 +228,128 @@ public class SalonController {
                             .orderByDesc(TrainOrder::getId)
                             .last("LIMIT 1"));
             row.put("orderNo", order == null ? null : order.getOrderNo());
+            // 沙龙状态(前端据此决定是否显示签到入口)
+            row.put("salonStatus", s == null ? null : s.getStatus());
+            // 已签到标记
+            Long checkedIn = trainSalonCheckinMapper.selectCount(
+                    new LambdaQueryWrapper<TrainSalonCheckin>()
+                            .eq(TrainSalonCheckin::getUid, userId)
+                            .eq(TrainSalonCheckin::getSalonId, e.getSalonId()));
+            row.put("checkedIn", checkedIn != null && checkedIn > 0);
+            // 已提交反馈标记
+            TrainSalonFeedback fb = e.getSalonId() == null ? null : trainSalonFeedbackMapper.selectOne(
+                    new LambdaQueryWrapper<TrainSalonFeedback>()
+                            .eq(TrainSalonFeedback::getUid, userId)
+                            .eq(TrainSalonFeedback::getSalonId, e.getSalonId())
+                            .last("LIMIT 1"));
+            row.put("feedbackSubmitted", fb != null);
             result.add(row);
         }
         return Result.success(result);
     }
+
+    /**
+     * 沙龙现场签到(需已报名且已支付/已确认;幂等,重复签到返回 true)
+     */
+    @Operation(summary = "沙龙签到")
+    @PostMapping("/checkin")
+    public Result<Boolean> checkin(@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());
+        TrainSalon salon = trainSalonMapper.selectById(salonId);
+        if (salon == null) {
+            return Result.error("沙龙不存在");
+        }
+        if (!"active".equals(salon.getStatus())) {
+            return Result.error("该沙龙未在进行中");
+        }
+        // 报名且已支付/已确认才可签到
+        Long enrolled = trainEnrollmentMapper.selectCount(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, userId)
+                        .eq(TrainEnrollment::getSalonId, salonId)
+                        .in(TrainEnrollment::getStatus, ACTIVE_STATUS));
+        if (enrolled == null || enrolled == 0) {
+            return Result.error("请先报名并完成支付");
+        }
+        // 幂等:已签到直接返回成功
+        Long existed = trainSalonCheckinMapper.selectCount(
+                new LambdaQueryWrapper<TrainSalonCheckin>()
+                        .eq(TrainSalonCheckin::getUid, userId)
+                        .eq(TrainSalonCheckin::getSalonId, salonId));
+        if (existed != null && existed > 0) {
+            return Result.success(true);
+        }
+        TrainSalonCheckin checkin = new TrainSalonCheckin();
+        checkin.setUid(userId);
+        checkin.setSalonId(salonId);
+        try {
+            trainSalonCheckinMapper.insert(checkin);
+        } catch (Exception ex) {
+            // 唯一键冲突(并发重复签到)按已签到处理
+        }
+        return Result.success(true);
+    }
+
+    /**
+     * 提交沙龙反馈(需已报名且已支付/已确认;评分 1-5;幂等,重复提交返回 true)
+     */
+    @Operation(summary = "提交沙龙反馈")
+    @PostMapping("/feedback")
+    public Result<Boolean> feedback(@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());
+        TrainSalon salon = trainSalonMapper.selectById(salonId);
+        if (salon == null) {
+            return Result.error("沙龙不存在");
+        }
+        Long enrolled = trainEnrollmentMapper.selectCount(
+                new LambdaQueryWrapper<TrainEnrollment>()
+                        .eq(TrainEnrollment::getUid, userId)
+                        .eq(TrainEnrollment::getSalonId, salonId)
+                        .in(TrainEnrollment::getStatus, ACTIVE_STATUS));
+        if (enrolled == null || enrolled == 0) {
+            return Result.error("请先报名参加该沙龙");
+        }
+        int rating = 5;
+        if (body.get("rating") != null) {
+            try {
+                rating = Integer.parseInt(body.get("rating").toString());
+            } catch (NumberFormatException e) {
+                rating = 5;
+            }
+        }
+        if (rating < 1 || rating > 5) {
+            return Result.error("评分需在 1-5 之间");
+        }
+        String comment = body.get("comment") == null ? "" : body.get("comment").toString().trim();
+        if (comment.length() > 500) {
+            comment = comment.substring(0, 500);
+        }
+        // 幂等:已提交直接返回成功
+        Long existed = trainSalonFeedbackMapper.selectCount(
+                new LambdaQueryWrapper<TrainSalonFeedback>()
+                        .eq(TrainSalonFeedback::getUid, userId)
+                        .eq(TrainSalonFeedback::getSalonId, salonId));
+        if (existed != null && existed > 0) {
+            return Result.success(true);
+        }
+        TrainSalonFeedback feedback = new TrainSalonFeedback();
+        feedback.setUid(userId);
+        feedback.setSalonId(salonId);
+        feedback.setRating(rating);
+        feedback.setComment(comment);
+        try {
+            trainSalonFeedbackMapper.insert(feedback);
+        } catch (Exception ex) {
+            // 唯一键冲突(并发重复提交)按已提交处理
+        }
+        return Result.success(true);
+    }
 }

+ 72 - 2
train-backend/src/main/java/com/train/controller/admin/AdminSalonController.java

@@ -4,8 +4,12 @@ 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.entity.TrainSalonCheckin;
+import com.train.entity.TrainSalonFeedback;
 import com.train.mapper.TrainSalonMapper;
 import com.train.mapper.TrainEnrollmentMapper;
+import com.train.mapper.TrainSalonCheckinMapper;
+import com.train.mapper.TrainSalonFeedbackMapper;
 import com.train.cfc.entity.CfcActivity;
 import com.train.cfc.mapper.CfcActivityMapper;
 import io.swagger.v3.oas.annotations.Operation;
@@ -26,8 +30,12 @@ public class AdminSalonController {
     private TrainSalonMapper trainSalonMapper;
     @Resource
     private TrainEnrollmentMapper trainEnrollmentMapper;
-    @Resource
+@Resource
     private CfcActivityMapper cfcActivityMapper;
+    @Resource
+    private TrainSalonCheckinMapper trainSalonCheckinMapper;
+    @Resource
+    private TrainSalonFeedbackMapper trainSalonFeedbackMapper;
 
     private static final List<String> OCCUPY_STATUS = Arrays.asList("pending", "paid", "confirmed");
 
@@ -212,7 +220,69 @@ public class AdminSalonController {
             row.put("source", e.getSource());
             row.put("inviteCode", e.getInviteCode());
             row.put("status", e.getStatus());
-            row.put("createdAt", e.getCreatedAt());
+row.put("createdAt", e.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "沙龙签到名单")
+    @PostMapping("/checkins")
+    public Result<List<Map<String, Object>>> checkins(@RequestBody Map<String, Object> body) {
+        if (body.get("salonId") == null) {
+            return Result.error("缺少沙龙ID");
+        }
+        Long salonId = Long.valueOf(body.get("salonId").toString());
+        List<TrainSalonCheckin> checkins = trainSalonCheckinMapper.selectList(
+                new LambdaQueryWrapper<TrainSalonCheckin>()
+                        .eq(TrainSalonCheckin::getSalonId, salonId)
+                        .orderByDesc(TrainSalonCheckin::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainSalonCheckin c : checkins) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("uid", c.getUid());
+            row.put("salonId", c.getSalonId());
+            row.put("createdAt", c.getCreatedAt());
+            // 关联报名信息(姓名/手机)
+            TrainEnrollment en = trainEnrollmentMapper.selectOne(
+                    new LambdaQueryWrapper<TrainEnrollment>()
+                            .eq(TrainEnrollment::getUid, c.getUid())
+                            .eq(TrainEnrollment::getSalonId, salonId)
+                            .last("LIMIT 1"));
+            row.put("name", en == null ? null : en.getName());
+            row.put("phone", en == null ? null : en.getPhone());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "沙龙反馈列表")
+    @PostMapping("/feedback/list")
+    public Result<List<Map<String, Object>>> feedbackList(@RequestBody Map<String, Object> body) {
+        if (body.get("salonId") == null) {
+            return Result.error("缺少沙龙ID");
+        }
+        Long salonId = Long.valueOf(body.get("salonId").toString());
+        List<TrainSalonFeedback> feedbacks = trainSalonFeedbackMapper.selectList(
+                new LambdaQueryWrapper<TrainSalonFeedback>()
+                        .eq(TrainSalonFeedback::getSalonId, salonId)
+                        .orderByDesc(TrainSalonFeedback::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainSalonFeedback f : feedbacks) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("uid", f.getUid());
+            row.put("salonId", f.getSalonId());
+            row.put("rating", f.getRating());
+            row.put("comment", f.getComment());
+            row.put("createdAt", f.getCreatedAt());
+            // 关联报名信息(姓名/手机)
+            TrainEnrollment en = trainEnrollmentMapper.selectOne(
+                    new LambdaQueryWrapper<TrainEnrollment>()
+                            .eq(TrainEnrollment::getUid, f.getUid())
+                            .eq(TrainEnrollment::getSalonId, salonId)
+                            .last("LIMIT 1"));
+            row.put("name", en == null ? null : en.getName());
+            row.put("phone", en == null ? null : en.getPhone());
             result.add(row);
         }
         return Result.success(result);

+ 25 - 0
train-backend/src/main/java/com/train/entity/TrainSalonCheckin.java

@@ -0,0 +1,25 @@
+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_checkin")
+public class TrainSalonCheckin implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 学员 ID(train_user.id) */
+    private Long uid;
+
+    /** 沙龙期次 ID(train_salon.id) */
+    private Long salonId;
+
+    private Date createdAt;
+}

+ 31 - 0
train-backend/src/main/java/com/train/entity/TrainSalonFeedback.java

@@ -0,0 +1,31 @@
+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_feedback")
+public class TrainSalonFeedback implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 学员 ID(train_user.id) */
+    private Long uid;
+
+    /** 沙龙期次 ID(train_salon.id) */
+    private Long salonId;
+
+    /** 评分 1-5 */
+    private Integer rating;
+
+    /** 留言内容 */
+    private String comment;
+
+    private Date createdAt;
+}

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

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

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

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

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

@@ -468,6 +468,28 @@ CREATE TABLE IF NOT EXISTS train_salon (
 -- 沙龙报名补列(存量库幂等迁移:重复执行报错被吞,属预期)
 ALTER TABLE train_enrollment ADD COLUMN salon_id BIGINT COMMENT '关联沙龙期次(train_salon.id)';
 
+-- 沙龙现场签到表(一人一期一条,唯一键防重复签到)
+CREATE TABLE IF NOT EXISTS train_salon_checkin (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    uid BIGINT NOT NULL COMMENT '学员(train_user.id)',
+    salon_id BIGINT NOT NULL COMMENT '沙龙期次(train_salon.id)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_uid_salon (uid, salon_id),
+    INDEX idx_salon_id (salon_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='沙龙签到';
+
+-- 沙龙会后反馈表(一人一期一条,唯一键防重复提交)
+CREATE TABLE IF NOT EXISTS train_salon_feedback (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    uid BIGINT NOT NULL COMMENT '学员(train_user.id)',
+    salon_id BIGINT NOT NULL COMMENT '沙龙期次(train_salon.id)',
+    rating INT DEFAULT 5 COMMENT '评分 1-5',
+    comment VARCHAR(500) COMMENT '留言内容',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_uid_salon (uid, salon_id),
+    INDEX idx_salon_id (salon_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='沙龙反馈';
+
 -- 完课证书表(编号体系:CM-{课程码}-{年份}-{序号},幂等发号)
 CREATE TABLE IF NOT EXISTS train_certificate (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 12 - 0
train-frontend/pages.json

@@ -120,6 +120,18 @@
         "navigationBarTitleText": "沙龙报名"
       }
     },
+    {
+      "path": "pages/salon/mine",
+      "style": {
+        "navigationBarTitleText": "我的沙龙"
+      }
+    },
+    {
+      "path": "pages/salon/feedback",
+      "style": {
+        "navigationBarTitleText": "沙龙反馈"
+      }
+    },
     {
       "path": "pages/pay/index",
       "style": {

+ 118 - 0
train-frontend/pages/salon/feedback.vue

@@ -0,0 +1,118 @@
+<template>
+  <view class="feedback-page">
+    <view class="section-card">
+      <text class="section-title">沙龙反馈</text>
+      <text class="card-desc">分享你对本次沙龙的感受与建议</text>
+
+      <view class="summary-box" v-if="salonTitle">
+        <text class="summary-text">{{ salonTitle }}</text>
+      </view>
+
+      <view class="rate-box">
+        <text class="form-label">整体评分</text>
+        <view class="star-row">
+          <text
+            v-for="star in 5"
+            :key="star"
+            class="star"
+            :class="star <= rating ? 'star-on' : 'star-off'"
+            @click="setRating(star)"
+          >★</text>
+          <text class="rate-tip">{{ ratingText }}</text>
+        </view>
+      </view>
+
+      <view class="form-item">
+        <text class="form-label">留言建议(选填)</text>
+        <textarea
+          class="form-input textarea"
+          v-model="comment"
+          maxlength="500"
+          placeholder="说说你对沙龙内容、讲师、场地的建议…"
+        />
+      </view>
+
+      <button class="submit-btn" @click="handleSubmit" :loading="loading" :disabled="loading">提交反馈</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { salonFeedback } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
+
+export default {
+  data() {
+    return {
+      salonId: '',
+      salonTitle: '',
+      rating: 5,
+      comment: '',
+      loading: false
+    }
+  },
+  onLoad(options) {
+    if (!guardPage()) return
+    this.salonId = options && options.salonId ? options.salonId : ''
+    this.salonTitle = options && options.title ? decodeURIComponent(options.title) : ''
+    if (!this.salonId) {
+      uni.showToast({ title: '参数错误', icon: 'none' })
+      setTimeout(function() {
+        uni.navigateBack({ delta: 1 })
+      }, 1500)
+    }
+  },
+  computed: {
+    ratingText() {
+      var map = { 1: '很差', 2: '较差', 3: '一般', 4: '满意', 5: '非常满意' }
+      return map[this.rating] || ''
+    }
+  },
+  methods: {
+    setRating: function(star) {
+      this.rating = star
+    },
+    handleSubmit: function() {
+      var self = this
+      if (self.loading) return
+      self.loading = true
+      var payload = {
+        salonId: self.salonId,
+        rating: self.rating,
+        comment: (self.comment || '').trim()
+      }
+      salonFeedback(payload).then(function() {
+        uni.showToast({ title: '提交成功', icon: 'success' })
+        setTimeout(function() {
+          uni.navigateBack({ delta: 1 })
+        }, 1200)
+      }).catch(function(err) {
+        uni.showToast({ title: (err && err.message) || '提交失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.feedback-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; }
+.summary-box { background: #FFF7ED; border-radius: 8rpx; padding: 20rpx 24rpx; margin-bottom: 24rpx; border: 2rpx solid #FED7AA; }
+.summary-text { display: block; font-size: 28rpx; color: #1E293B; font-weight: 600; }
+.rate-box { margin-bottom: 24rpx; }
+.form-label { display: block; font-size: 26rpx; color: #475569; font-weight: 500; margin-bottom: 12rpx; }
+.star-row { display: flex; align-items: center; }
+.star { font-size: 56rpx; margin-right: 16rpx; }
+.star-on { color: #F97316; }
+.star-off { color: #CBD5E1; }
+.rate-tip { font-size: 26rpx; color: #64748B; margin-left: 8rpx; }
+.form-item { margin-bottom: 24rpx; }
+.form-input { width: 100%; box-sizing: border-box; border: 2rpx solid #E2E8F0; border-radius: 8rpx; padding: 16rpx; font-size: 28rpx; color: #1E293B; background: #F8FAFC; }
+.textarea { height: 240rpx; }
+.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>

+ 12 - 2
train-frontend/pages/salon/list.vue

@@ -1,8 +1,13 @@
 <template>
   <view class="salon-page">
     <view class="section-card">
-      <text class="section-title">周末沙龙</text>
-      <text class="card-desc">探索AI前沿,共创成长空间</text>
+      <view class="head-row">
+        <view>
+          <text class="section-title">周末沙龙</text>
+          <text class="card-desc">探索AI前沿,共创成长空间</text>
+        </view>
+        <text class="mine-link" @click="goMine">我的报名</text>
+      </view>
       
       <view v-if="loading" class="empty-tip">加载中…</view>
       <view v-else-if="salons.length === 0" class="empty-tip">暂无活动沙龙</view>
@@ -99,6 +104,9 @@ export default {
              '&name=' + encodeURIComponent(userInfo.name || '') + 
              '&phone=' + encodeURIComponent(userInfo.phone || '') 
       })
+    },
+    goMine() {
+      uni.navigateTo({ url: '/pages/salon/mine' })
     }
   }
 }
@@ -107,6 +115,8 @@ export default {
 <style scoped>
 .salon-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
 .section-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; }
+.head-row { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 8rpx; }
+.mine-link { font-size: 24rpx; color: #F97316; flex-shrink: 0; padding-top: 4rpx; }
 .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; }

+ 170 - 0
train-frontend/pages/salon/mine.vue

@@ -0,0 +1,170 @@
+<template>
+  <view class="mine-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="salon-card" v-for="item in list" :key="item.enrollmentId">
+          <view class="salon-head">
+            <text class="salon-theme">{{ mapTheme(item.theme) }}</text>
+            <text class="salon-status" v-if="item.checkedIn">已签到</text>
+          </view>
+          <text class="salon-title">{{ item.title }}</text>
+
+          <view class="salon-info">
+            <view class="info-item">
+              <text class="info-label">时间:</text>
+              <text class="info-value">{{ 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">{{ statusText(item.status) }}</text>
+            </view>
+          </view>
+
+          <view class="salon-foot">
+            <button
+              v-if="canCheckin(item)"
+              class="mini-btn primary"
+              :loading="checking"
+              :disabled="checking"
+              @click="handleCheckin(item)"
+            >现场签到</button>
+            <text v-else-if="item.checkedIn" class="foot-tip">已完成签到</text>
+
+            <button
+              v-if="canFeedback(item)"
+              class="mini-btn plain"
+              :disabled="submitting"
+              @click="goFeedback(item)"
+            >{{ item.feedbackSubmitted ? '查看反馈' : '去反馈' }}</button>
+            <button
+              v-if="isUnpaid(item)"
+              class="mini-btn plain"
+              @click="goPay(item)"
+            >去支付</button>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMySalons, salonCheckin } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false,
+      checking: false,
+      submitting: false,
+      themeMap: {
+        ai_basic: 'AI 认知基础',
+        wealth: '家庭财富管理',
+        health: '健康管理',
+        growth: '孩子成长陪伴'
+      }
+    }
+  },
+  onShow() {
+    if (!guardPage()) return
+    this.loadMine()
+  },
+  methods: {
+    mapTheme: function(theme) {
+      return this.themeMap[theme] || theme || '通用主题'
+    },
+    statusText: function(status) {
+      var map = {
+        pending: '待支付',
+        paid: '已支付',
+        confirmed: '已确认',
+        rejected: '已拒绝',
+        cancelled: '已取消'
+      }
+      return map[status] || status || ''
+    },
+    canCheckin: function(item) {
+      // 已支付/已确认 且 沙龙进行中 且 未签到
+      return (item.status === 'paid' || item.status === 'confirmed') &&
+        item.salonStatus === 'active' && !item.checkedIn
+    },
+    canFeedback: function(item) {
+      // 已支付/已确认即可反馈(已提交则入口改为查看)
+      return item.status === 'paid' || item.status === 'confirmed'
+    },
+    isUnpaid: function(item) {
+      return item.status === 'pending' && item.orderNo
+    },
+    loadMine: function() {
+      var self = this
+      self.loading = true
+      getMySalons().then(function(resp) {
+        self.list = resp.data || []
+      }).catch(function() {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    handleCheckin: function(item) {
+      var self = this
+      if (self.checking) return
+      self.checking = true
+      salonCheckin(item.salonId).then(function() {
+        uni.showToast({ title: '签到成功', icon: 'success' })
+        item.checkedIn = true
+      }).catch(function(err) {
+        uni.showToast({ title: (err && err.message) || '签到失败', icon: 'none' })
+        self.loadMine()
+      }).finally(function() {
+        self.checking = false
+      })
+    },
+    goFeedback: function(item) {
+      uni.navigateTo({
+        url: '/pages/salon/feedback?salonId=' + item.salonId +
+          '&title=' + encodeURIComponent(item.title)
+      })
+    },
+    goPay: function(item) {
+      uni.navigateTo({
+        url: '/pages/pay/index?enrollmentId=' + item.enrollmentId
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mine-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 { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx; }
+.salon-theme { display: block; background: #FFF7ED; color: #F97316; font-size: 22rpx; font-weight: 600; padding: 4rpx 12rpx; border-radius: 4rpx; }
+.salon-status { font-size: 22rpx; color: #22C55E; font-weight: 600; }
+.salon-title { display: block; font-size: 30rpx; font-weight: 600; color: #1E293B; margin-bottom: 12rpx; }
+.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-foot { display: flex; align-items: center; justify-content: flex-end; gap: 16rpx; }
+.mini-btn { height: 60rpx; line-height: 60rpx; font-size: 26rpx; border-radius: 30rpx; border: none; margin: 0; padding: 0 32rpx; }
+.mini-btn.primary { background: #F97316; color: #FFF; }
+.mini-btn.plain { background: #FFF; color: #F97316; border: 2rpx solid #F97316; }
+.mini-btn:active { opacity: 0.85; }
+.foot-tip { font-size: 24rpx; color: #94A3B8; }
+</style>

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

@@ -245,6 +245,12 @@ export const createSalonEnroll = (data) => {
 export const getMySalons = () => {
   return request('/api/salon/mine', 'POST')
 }
+export const salonCheckin = (salonId) => {
+  return request('/api/salon/checkin', 'POST', { salonId })
+}
+export const salonFeedback = (data) => {
+  return request('/api/salon/feedback', 'POST', data)
+}
 
 // 支付
 export const createPayOrder = (data) => {

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

@@ -15,3 +15,11 @@ export function salonStatus(data) {
 export function salonRegistrations(data) {
   return request.post('/api/admin/salon/registrations', data)
 }
+
+export function salonCheckins(data) {
+  return request.post('/api/admin/salon/checkins', data)
+}
+
+export function salonFeedbackList(data) {
+  return request.post('/api/admin/salon/feedback/list', data)
+}

+ 52 - 1
train-web/src/views/Salons.vue

@@ -33,6 +33,7 @@
             {{ row.status === 'active' ? '结束' : '重新上架' }}
           </el-button>
           <el-button size="mini" type="text" @click="viewRegistrations(row)">报名名单</el-button>
+          <el-button size="mini" type="text" @click="viewFeedback(row)">反馈</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -88,14 +89,32 @@
             <el-tag size="mini">{{ row.status }}</el-tag>
           </template>
         </el-table-column>
+        <el-table-column label="签到" width="80">
+          <template slot-scope="{ row }">
+            <el-tag size="mini" :type="row.checkedIn ? 'success' : 'info'">{{ row.checkedIn ? '已签到' : '未签' }}</el-tag>
+          </template>
+        </el-table-column>
         <el-table-column prop="createdAt" label="报名时间" width="170" :formatter="formatTime" />
       </el-table>
     </el-dialog>
+
+    <!-- 反馈 List Dialog -->
+    <el-dialog title="沙龙反馈" :visible.sync="showFeedbackDialog" width="700px">
+      <el-table :data="feedbacks" v-loading="fbLoading" border stripe>
+        <el-table-column prop="name" label="姓名" width="100" />
+        <el-table-column prop="phone" label="手机" width="130" />
+        <el-table-column label="评分" width="80">
+          <template slot-scope="{ row }">{{ starText(row.rating) }}</template>
+        </el-table-column>
+        <el-table-column prop="comment" label="留言" min-width="200" show-overflow-tooltip />
+        <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'
+import { salonList, createSalon, salonStatus, salonRegistrations, salonCheckins, salonFeedbackList } from '@/api/salon'
 
 export default {
   name: 'Salons',
@@ -108,6 +127,9 @@ export default {
       showRegDialog: false,
       regLoading: false,
       registrations: [],
+      showFeedbackDialog: false,
+      fbLoading: false,
+      feedbacks: [],
       themeMap: {
         ai_basic: 'AI 认知基础',
         wealth: '家庭财富管理',
@@ -213,11 +235,40 @@ export default {
       self.regLoading = true
       salonRegistrations({ salonId: row.id }).then(function (res) {
         self.registrations = res.data || []
+        // 拉取签到名单,标注已签到者
+        return salonCheckins({ salonId: row.id })
+      }).then(function (res2) {
+        var checkedUids = (res2.data || []).map(function (c) {
+          return Number(c.uid)
+        })
+        self.registrations.forEach(function (r) {
+          r.checkedIn = checkedUids.indexOf(Number(r.uid)) >= 0
+        })
       }).catch(function () {
         self.$message.error('获取报名名单失败')
       }).finally(function () {
         self.regLoading = false
       })
+    },
+    viewFeedback: function (row) {
+      var self = this
+      self.showFeedbackDialog = true
+      self.fbLoading = true
+      salonFeedbackList({ salonId: row.id }).then(function (res) {
+        self.feedbacks = res.data || []
+      }).catch(function () {
+        self.$message.error('获取反馈失败')
+      }).finally(function () {
+        self.fbLoading = false
+      })
+    },
+    starText: function (rating) {
+      var stars = ''
+      var n = Number(rating) || 0
+      for (var i = 0; i < 5; i++) {
+        stars += i < n ? '★' : '☆'
+      }
+      return stars || '-'
     }
   }
 }