Преглед изворни кода

feat: 活动报名服务 + TrialMembership入门会员

- ActivityRegistrationService: 活动报名核心服务

- TrialMembership: 入门会员体验实体与Mapper

- 前端页面调整(splash引导页改版)
User пре 4 месеци
родитељ
комит
06e9784e3a

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TrialMembership.java

@@ -0,0 +1,33 @@
+package com.etotem.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.util.Date;
+
+@Data
+@TableName("trial_memberships")
+public class TrialMembership implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long familyId;
+
+    private Date startDate;
+
+    private Date endDate;
+
+    private Integer trialDays;
+
+    private String status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TrialMembershipMapper.java

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

+ 142 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java

@@ -0,0 +1,142 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class ActivityRegistrationService {
+    @Resource
+    private ActivityRegistrationMapper registrationMapper;
+    @Resource
+    private ActivityMapper activityMapper;
+
+    @Transactional
+    public Result<Map<String, Object>> register(Long activityId, Long userId, Long childId) {
+        Activity activity = activityMapper.selectById(activityId);
+        if (activity == null) return Result.error("活动不存在");
+        if (!"published".equals(activity.getStatus())) return Result.error("活动未发布");
+        if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 0) {
+            return Result.error("该活动无需报名");
+        }
+        if (isRegistered(activityId, childId)) return Result.error("您已报名此活动");
+        ActivityRegistration reg = new ActivityRegistration();
+        reg.setActivityId(activityId); reg.setUserId(userId); reg.setChildId(childId);
+        reg.setStatus("pending"); reg.setRegisteredAt(LocalDateTime.now());
+        reg.setCreatedAt(LocalDateTime.now()); reg.setUpdatedAt(LocalDateTime.now());
+        registrationMapper.insert(reg);
+        Map<String, Object> result = new HashMap<>();
+        result.put("registrationId", reg.getId()); result.put("status", "pending");
+        return Result.success(result);
+    }
+
+    @Transactional
+    public Result<Void> cancel(Long activityId, Long userId, Long childId) {
+        ActivityRegistration reg = findRegistration(activityId, childId, userId, "pending");
+        if (reg == null) return Result.error("报名记录不存在");
+        reg.setStatus("cancelled"); reg.setUpdatedAt(LocalDateTime.now());
+        registrationMapper.updateById(reg);
+        return Result.success(null);
+    }
+
+    @Transactional
+    public Result<Void> approve(Long registrationId, Long adminUserId) {
+        ActivityRegistration reg = registrationMapper.selectById(registrationId);
+        if (reg == null) return Result.error("报名记录不存在");
+        if (!"pending".equals(reg.getStatus())) return Result.error("当前状态不允许审核");
+        reg.setStatus("approved"); reg.setApprovedAt(LocalDateTime.now());
+        reg.setApprovedBy(adminUserId); reg.setUpdatedAt(LocalDateTime.now());
+        registrationMapper.updateById(reg);
+        return Result.success(null);
+    }
+
+    @Transactional
+    public Result<Void> reject(Long registrationId, Long adminUserId) {
+        ActivityRegistration reg = registrationMapper.selectById(registrationId);
+        if (reg == null) return Result.error("报名记录不存在");
+        if (!"pending".equals(reg.getStatus())) return Result.error("当前状态不允许审核");
+        reg.setStatus("rejected"); reg.setApprovedAt(LocalDateTime.now());
+        reg.setApprovedBy(adminUserId); reg.setUpdatedAt(LocalDateTime.now());
+        registrationMapper.updateById(reg);
+        return Result.success(null);
+    }
+
+    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long childId, Integer page, Integer size) {
+        if (page == null || page < 1) page = 1;
+        if (size == null || size < 1) size = 10;
+        LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<>();
+        if (userId != null) wrapper.eq(ActivityRegistration::getUserId, userId);
+        if (childId != null) wrapper.eq(ActivityRegistration::getChildId, childId);
+        wrapper.orderByDesc(ActivityRegistration::getRegisteredAt);
+        Page<ActivityRegistration> pageResult = registrationMapper.selectPage(new Page<>(page, size), wrapper);
+        List<Map<String, Object>> records = pageResult.getRecords().stream().map(reg -> {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", reg.getId()); item.put("activityId", reg.getActivityId());
+            item.put("childId", reg.getChildId()); item.put("status", reg.getStatus());
+            item.put("registeredAt", reg.getRegisteredAt());
+            Activity activity = activityMapper.selectById(reg.getActivityId());
+            if (activity != null) {
+                item.put("activityTitle", activity.getTitle());
+                item.put("activityStartTime", activity.getStartTime());
+            }
+            return item;
+        }).collect(Collectors.toList());
+        Map<String, Object> data = new HashMap<>();
+        data.put("records", records); data.put("total", pageResult.getTotal());
+        data.put("page", pageResult.getCurrent()); data.put("size", pageResult.getSize());
+        return Result.success(data);
+    }
+
+    public Result<List<Map<String, Object>>> getRegistrationsByActivity(Long activityId, String status) {
+        LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getActivityId, activityId).orderByDesc(ActivityRegistration::getRegisteredAt);
+        if (status != null && !status.isEmpty()) wrapper.eq(ActivityRegistration::getStatus, status);
+        List<ActivityRegistration> list = registrationMapper.selectList(wrapper);
+        List<Map<String, Object>> result = list.stream().map(reg -> {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", reg.getId()); item.put("childId", reg.getChildId());
+            item.put("userId", reg.getUserId()); item.put("status", reg.getStatus());
+            item.put("registeredAt", reg.getRegisteredAt());
+            return item;
+        }).collect(Collectors.toList());
+        return Result.success(result);
+    }
+
+    public boolean isRegistered(Long activityId, Long childId) {
+        return registrationMapper.selectOne(new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getActivityId, activityId)
+            .eq(ActivityRegistration::getChildId, childId)
+            .in(ActivityRegistration::getStatus, "pending", "approved").last("LIMIT 1")) != null;
+    }
+
+    public boolean isApproved(Long activityId, Long childId) {
+        return registrationMapper.selectOne(new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getActivityId, activityId)
+            .eq(ActivityRegistration::getChildId, childId)
+            .eq(ActivityRegistration::getStatus, "approved").last("LIMIT 1")) != null;
+    }
+
+    private ActivityRegistration findRegistration(Long activityId, Long childId, Long userId, String status) {
+        LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getActivityId, activityId)
+            .eq(ActivityRegistration::getChildId, childId)
+            .eq(ActivityRegistration::getUserId, userId);
+        if (status != null) wrapper.eq(ActivityRegistration::getStatus, status);
+        wrapper.last("LIMIT 1");
+        return registrationMapper.selectOne(wrapper);
+    }
+}

+ 1 - 7
cfc-frontend/pages.json

@@ -128,12 +128,6 @@
       "style": {
         "navigationBarTitleText": "我的优惠券"
       }
-    },
-    {
-      "path": "pages/membership/index",
-      "style": {
-        "navigationBarTitleText": "会员中心"
-      }
     }
   ],
   "subPackages": [
@@ -793,4 +787,4 @@
       }
     ]
   }
-}
+}

+ 11 - 6
cfc-frontend/pages/index/index.vue

@@ -160,12 +160,17 @@ export default {
     this._doInit(options)
   },
   onShow() {
-    // Tabbar 切换过渡动画(最少停留 1 秒)
-    this.showTabTransition = true
-    var self = this
-    setTimeout(function() {
-      self.showTabTransition = false
-    }, 1100)
+    // Tabbar 切换过渡动画:从 splash 首次进入时跳过(splash 本身就是过渡)
+    var fromSplash = uni.getStorageSync('_fromSplash')
+    if (fromSplash === '1') {
+      uni.removeStorageSync('_fromSplash')
+    } else {
+      this.showTabTransition = true
+      var self = this
+      setTimeout(function() {
+        self.showTabTransition = false
+      }, 1100)
+    }
 
     // 始终执行初始化,确保登录态变更后及时切换视图
     this._doInit()

+ 113 - 57
cfc-frontend/pages/splash/splash.vue

@@ -1,42 +1,50 @@
 <template>
   <view class="splash-root">
+    <!-- 背景装饰 -->
+    <view class="splash-bg-decor">
+      <view class="bg-circle bg-circle-1"></view>
+      <view class="bg-circle bg-circle-2"></view>
+      <view class="bg-circle bg-circle-3"></view>
+    </view>
+
     <view class="splash-content">
-      <!-- Brand Logo Mark -->
-      <view class="splash-logo-mark">
-        <text class="splash-logo-char">浠</text>
+      <!-- Logo 图标 -->
+      <view class="splash-logo-wrap">
+        <image class="splash-logo-img" src="/static/logo.png" mode="aspectFit" />
       </view>
 
       <!-- Brand Name -->
       <text class="splash-brand-name">浠艾福</text>
+      <text class="splash-brand-en">Care Family</text>
 
       <!-- 五维图标行 -->
       <view class="splash-dims-row">
         <view class="splash-dim-item">
-          <view class="splash-dim-circle" style="background:rgba(141,110,99,0.25);">
+          <view class="splash-dim-circle" style="background:rgba(255,140,66,0.3);">
             <text class="splash-dim-icon">🌏</text>
           </view>
           <text class="splash-dim-label">身</text>
         </view>
         <view class="splash-dim-item">
-          <view class="splash-dim-circle" style="background:rgba(255,107,53,0.25);">
+          <view class="splash-dim-circle" style="background:rgba(255,107,157,0.3);">
             <text class="splash-dim-icon">🔥</text>
           </view>
           <text class="splash-dim-label">心</text>
         </view>
         <view class="splash-dim-item">
-          <view class="splash-dim-circle" style="background:rgba(255,215,0,0.25);">
+          <view class="splash-dim-circle" style="background:rgba(99,102,241,0.3);">
             <text class="splash-dim-icon">⚡</text>
           </view>
           <text class="splash-dim-label">智</text>
         </view>
         <view class="splash-dim-item">
-          <view class="splash-dim-circle" style="background:rgba(76,175,80,0.25);">
+          <view class="splash-dim-circle" style="background:rgba(16,185,129,0.3);">
             <text class="splash-dim-icon">🌿</text>
           </view>
           <text class="splash-dim-label">行</text>
         </view>
         <view class="splash-dim-item">
-          <view class="splash-dim-circle" style="background:rgba(66,165,245,0.25);">
+          <view class="splash-dim-circle" style="background:rgba(245,158,11,0.3);">
             <text class="splash-dim-icon">💧</text>
           </view>
           <text class="splash-dim-label">富</text>
@@ -65,16 +73,17 @@ export default {
     }
   },
   onLoad() {
-    // 标记 splash 已展示
     this.ready = true
   },
   onReady() {
-    // 延迟 2500ms 后跳转到首页(至少 1s 展示保证)
+    // 标记来自 splash 页,首页首次加载跳过 tab-transition
+    uni.setStorageSync('_fromSplash', '1')
+    // 至少展示 1 秒后跳转
     setTimeout(function() {
       uni.switchTab({
         url: '/pages/index/index'
       })
-    }, 2500)
+    }, 1200)
   }
 }
 </script>
@@ -89,10 +98,60 @@ export default {
   display: flex;
   align-items: center;
   justify-content: center;
-  background: linear-gradient(160deg, #FFF7ED 0%, #F97316 100%);
+  background: linear-gradient(160deg, #FFF7ED 0%, #FFEDD5 50%, #FED7AA 100%);
   z-index: 9999;
+  overflow: hidden;
+}
+
+/* ===== 背景装饰 ===== */
+.splash-bg-decor {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  pointer-events: none;
+}
+
+.bg-circle {
+  position: absolute;
+  border-radius: 50%;
+  opacity: 0.15;
+}
+
+.bg-circle-1 {
+  width: 400rpx;
+  height: 400rpx;
+  background: #FF8C42;
+  top: -100rpx;
+  right: -100rpx;
+  animation: float 6s ease-in-out infinite;
+}
+
+.bg-circle-2 {
+  width: 300rpx;
+  height: 300rpx;
+  background: #F59E0B;
+  bottom: 100rpx;
+  left: -80rpx;
+  animation: float 8s ease-in-out infinite reverse;
 }
 
+.bg-circle-3 {
+  width: 200rpx;
+  height: 200rpx;
+  background: #FF6B9D;
+  top: 300rpx;
+  right: 50rpx;
+  animation: float 5s ease-in-out infinite 1s;
+}
+
+@keyframes float {
+  0%, 100% { transform: translateY(0) scale(1); }
+  50% { transform: translateY(-20rpx) scale(1.05); }
+}
+
+/* ===== 主内容 ===== */
 .splash-content {
   display: flex;
   flex-direction: column;
@@ -104,7 +163,7 @@ export default {
 @keyframes splash-fade-in {
   from {
     opacity: 0;
-    transform: translateY(20rpx);
+    transform: translateY(30rpx);
   }
   to {
     opacity: 1;
@@ -112,42 +171,47 @@ export default {
   }
 }
 
-/* ===== Logo Mark ===== */
-.splash-logo-mark {
-  width: 144rpx;
-  height: 144rpx;
-  border-radius: 36rpx;
-  background: rgba(255, 255, 255, 0.3);
+/* ===== Logo ===== */
+.splash-logo-wrap {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 40rpx;
+  background: rgba(255, 255, 255, 0.95);
   display: flex;
   align-items: center;
   justify-content: center;
   margin-bottom: 24rpx;
-  backdrop-filter: blur(4px);
+  box-shadow: 0 8rpx 32rpx rgba(249, 115, 22, 0.25);
 }
 
-.splash-logo-char {
-  font-size: 72rpx;
-  font-weight: 700;
-  color: #FFFFFF;
-  line-height: 1;
+.splash-logo-img {
+  width: 100rpx;
+  height: 100rpx;
 }
 
 /* ===== Brand Name ===== */
 .splash-brand-name {
-  font-size: 56rpx;
+  font-size: 60rpx;
   font-weight: 700;
-  color: #FFFFFF;
+  color: #7C2D12;
   letter-spacing: 8rpx;
-  margin-bottom: 12rpx;
+  margin-bottom: 4rpx;
   text-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
 }
 
+.splash-brand-en {
+  font-size: 22rpx;
+  color: rgba(124, 45, 18, 0.6);
+  letter-spacing: 4rpx;
+  margin-bottom: 20rpx;
+}
+
 /* ===== Tagline ===== */
 .splash-tagline {
   font-size: 28rpx;
-  color: rgba(255, 255, 255, 0.85);
-  letter-spacing: 4rpx;
-  font-weight: 400;
+  color: rgba(124, 45, 18, 0.8);
+  letter-spacing: 2rpx;
+  font-weight: 500;
 }
 
 /* ===== Loading Dots ===== */
@@ -166,21 +230,13 @@ export default {
   width: 14rpx;
   height: 14rpx;
   border-radius: 50%;
-  background: rgba(255, 255, 255, 0.6);
+  background: rgba(249, 115, 22, 0.6);
   animation: splash-dot-bounce 1.4s ease-in-out infinite both;
 }
 
-.splash-dot--1 {
-  animation-delay: -0.32s;
-}
-
-.splash-dot--2 {
-  animation-delay: -0.16s;
-}
-
-.splash-dot--3 {
-  animation-delay: 0s;
-}
+.splash-dot--1 { animation-delay: -0.32s; }
+.splash-dot--2 { animation-delay: -0.16s; }
+.splash-dot--3 { animation-delay: 0s; }
 
 @keyframes splash-dot-bounce {
   0%, 80%, 100% {
@@ -199,8 +255,8 @@ export default {
   flex-direction: row;
   align-items: center;
   justify-content: center;
-  gap: 16rpx;
-  margin: 16rpx 0 20rpx;
+  gap: 20rpx;
+  margin: 24rpx 0 24rpx;
 }
 
 .splash-dim-item {
@@ -213,15 +269,15 @@ export default {
   animation: splash-dim-enter 0.4s ease forwards;
 }
 
-.splash-dim-item:nth-child(1) { animation-delay: 0.8s; }
-.splash-dim-item:nth-child(2) { animation-delay: 1.05s; }
-.splash-dim-item:nth-child(3) { animation-delay: 1.3s; }
-.splash-dim-item:nth-child(4) { animation-delay: 1.55s; }
-.splash-dim-item:nth-child(5) { animation-delay: 1.8s; }
+.splash-dim-item:nth-child(1) { animation-delay: 0.4s; }
+.splash-dim-item:nth-child(2) { animation-delay: 0.55s; }
+.splash-dim-item:nth-child(3) { animation-delay: 0.7s; }
+.splash-dim-item:nth-child(4) { animation-delay: 0.85s; }
+.splash-dim-item:nth-child(5) { animation-delay: 1.0s; }
 
 .splash-dim-circle {
-  width: 64rpx;
-  height: 64rpx;
+  width: 68rpx;
+  height: 68rpx;
   border-radius: 50%;
   display: flex;
   align-items: center;
@@ -229,15 +285,15 @@ export default {
 }
 
 .splash-dim-icon {
-  font-size: 28rpx;
+  font-size: 30rpx;
   line-height: 1;
-  color: #FFFFFF;
 }
 
 .splash-dim-label {
-  font-size: 18rpx;
-  color: rgba(255, 255, 255, 0.7);
+  font-size: 20rpx;
+  color: rgba(124, 45, 18, 0.7);
   line-height: 1;
+  font-weight: 500;
 }
 
 @keyframes splash-dim-enter {
@@ -250,4 +306,4 @@ export default {
     transform: translateY(0);
   }
 }
-</style>
+</style>