浏览代码

feat: 学员端登录态统一;未登录可浏览课程但操作需登录

liaoxg 1 周之前
父节点
当前提交
c85d2ba
共有 34 个文件被更改,包括 184 次插入199 次删除
  1. 2 0
      train-backend/src/main/java/com/train/config/JwtInterceptor.java
  2. 17 12
      train-backend/src/main/java/com/train/controller/CourseController.java
  3. 33 4
      train-backend/src/main/java/com/train/controller/auth/AuthController.java
  4. 5 19
      train-frontend/App.vue
  5. 0 7
      train-frontend/pages.json
  6. 3 1
      train-frontend/pages/assignment/index.vue
  7. 2 0
      train-frontend/pages/case/index.vue
  8. 3 1
      train-frontend/pages/checkin/index.vue
  9. 2 0
      train-frontend/pages/class/index.vue
  10. 18 8
      train-frontend/pages/course/detail.vue
  11. 4 1
      train-frontend/pages/course/index.vue
  12. 2 0
      train-frontend/pages/enroll/form.vue
  13. 2 0
      train-frontend/pages/enroll/list.vue
  14. 2 0
      train-frontend/pages/group-order/create.vue
  15. 2 0
      train-frontend/pages/group-order/detail.vue
  16. 2 0
      train-frontend/pages/group-order/index.vue
  17. 3 1
      train-frontend/pages/group/index.vue
  18. 0 141
      train-frontend/pages/login/index.vue
  19. 2 0
      train-frontend/pages/material/index.vue
  20. 16 2
      train-frontend/pages/mine/index.vue
  21. 2 0
      train-frontend/pages/pay/index.vue
  22. 2 0
      train-frontend/pages/pay/result.vue
  23. 2 0
      train-frontend/pages/plan/index.vue
  24. 2 0
      train-frontend/pages/roadmap/index.vue
  25. 2 0
      train-frontend/pages/scoreboard/index.vue
  26. 2 0
      train-frontend/pages/share/index.vue
  27. 2 0
      train-frontend/pages/share/stats.vue
  28. 2 0
      train-frontend/pages/survey/index.vue
  29. 2 0
      train-frontend/pages/teaching/index.vue
  30. 3 1
      train-frontend/pages/upload/index.vue
  31. 2 0
      train-frontend/pages/verify/index.vue
  32. 2 0
      train-frontend/pages/vote/index.vue
  33. 17 1
      train-frontend/store/index.js
  34. 22 0
      train-frontend/utils/guard.js

+ 2 - 0
train-backend/src/main/java/com/train/config/JwtInterceptor.java

@@ -34,6 +34,8 @@ public class JwtInterceptor implements HandlerInterceptor {
         "/api/auth/wechat-phone-login", // 微信手机号登录(登录阶段无 token)
         "/api/class/join/verify",     // 进班校验(学号+邀请码,尚未登录时走公开?——实际需登录,调整)
         "/api/pay/notify",            // 微信支付回调(公网)
+        "/api/course/list",           // 课程列表:未登录可浏览课程信息(我的报名状态置 none)
+        "/api/course/current",        // 课程信息:未登录可浏览课程介绍(班次/报名状态留空)
         "/api/public/",
     };
 

+ 17 - 12
train-backend/src/main/java/com/train/controller/CourseController.java

@@ -59,27 +59,32 @@ public class CourseController {
     @Resource
     private CertService certService;
 
-    @Operation(summary = "列出所有课程(含有效状态与我的进班状态)")
+    @Operation(summary = "列出所有课程(含有效状态与我的进班状态,未登录可浏览,我的状态为 none)")
     @PostMapping("/list")
-    public Result<List<TrainCourse>> list(@RequestAttribute("userId") Long userId) {
+    public Result<List<TrainCourse>> list(@RequestAttribute(value = "userId", required = false) Long userId) {
         List<TrainCourse> courses = trainCourseMapper.selectList(
                 new LambdaQueryWrapper<TrainCourse>().orderByAsc(TrainCourse::getLevel));
         for (TrainCourse c : courses) {
             c.setEffectiveStatus(effectiveStatus(c));
-            Map<String, Object> en = myEnrollmentStatus(userId, c.getId());
-            c.setMyStatus(en == null ? "none" : en.get("status").toString());
+            if (userId == null) {
+                // 未登录浏览:我的报名/进班状态展示为 none
+                c.setMyStatus("none");
+            } else {
+                Map<String, Object> en = myEnrollmentStatus(userId, c.getId());
+                c.setMyStatus(en == null ? "none" : en.get("status").toString());
+            }
         }
         return Result.success(courses);
     }
 
-    @Operation(summary = "当前学员所修课程(可传 courseId 指定查询某一门课程)")
+    @Operation(summary = "当前学员所修课程(可传 courseId 指定查询某一门课程;未登录时仅返回课程/班次浏览信息)")
     @PostMapping("/current")
     public Result<Map<String, Object>> current(@RequestBody(required = false) Map<String, Object> body,
-                                               @RequestAttribute("userId") Long userId) {
+                                               @RequestAttribute(value = "userId", required = false) Long userId) {
         Map<String, Object> data = new HashMap<>();
 
-        TrainUser user = trainUserMapper.selectById(userId);
-        if (user == null) {
+        TrainUser user = userId == null ? null : trainUserMapper.selectById(userId);
+        if (userId != null && user == null) {
             return Result.error("用户不存在");
         }
 
@@ -91,7 +96,7 @@ public class CourseController {
         if (targetCourseId != null) {
             // 指定课程:直接查该课程,并解析其中的班次/报名状态
             course = trainCourseMapper.selectById(targetCourseId);
-            if (user.getClassId() != null) {
+            if (user != null && user.getClassId() != null) {
                 TrainClass myClass = trainClassMapper.selectById(user.getClassId());
                 // 仅当用户所在班次归属该课程时带上班级信息
                 if (myClass != null && targetCourseId.equals(myClass.getCourseId())) {
@@ -100,7 +105,7 @@ public class CourseController {
             }
         } else {
             // 未指定:按 user.class_id -> train_class.course_id 解析
-            if (user.getClassId() != null) {
+            if (user != null && user.getClassId() != null) {
                 trainClass = trainClassMapper.selectById(user.getClassId());
                 if (trainClass != null && trainClass.getCourseId() != null) {
                     course = trainCourseMapper.selectById(trainClass.getCourseId());
@@ -144,8 +149,8 @@ public class CourseController {
                 data.put("classes", classes);
             }
         }
-        // 学员在该课程下的报名/进班状态
-        data.put("enrollment", myEnrollmentStatus(userId, course.getId()));
+        // 学员在该课程下的报名/进班状态(未登录浏览时无报名状态)
+        data.put("enrollment", userId == null ? null : myEnrollmentStatus(userId, course.getId()));
         return Result.success(data);
     }
 

+ 33 - 4
train-backend/src/main/java/com/train/controller/auth/AuthController.java

@@ -1,5 +1,6 @@
 package com.train.controller.auth;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.common.Result;
 import com.train.config.JwtConfig;
 import com.train.entity.TrainUser;
@@ -14,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.util.StringUtils;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -88,19 +90,46 @@ public class AuthController {
         CfcUser cfcUser = upsertCfcUser(openid);
 
         // 手机号回写到 cfc_user + train_user
+        // 对齐 cfc 语义:phone 优先回填 openid(用户换了微信号,按手机号找到原账号复用),
+        // 避免 test-mode 占位 phone 13800138000 多人共用时撞 cfc users.uk_phone
         String phone = wechatService.getPhoneNumber(phoneCode);
-        if (StringUtils.hasText(phone)) {
-            if (!phone.equals(cfcUser.getPhone())) {
+        if (StringUtils.hasText(phone) && !phone.equals(cfcUser.getPhone())) {
+            CfcUser phoneOwner = cfcUserMapper.selectOne(
+                    new LambdaQueryWrapper<CfcUser>()
+                            .eq(CfcUser::getPhone, phone)
+                            .ne(CfcUser::getId, cfcUser.getId())
+                            .last("LIMIT 1"));
+            if (phoneOwner != null) {
+                log.warn("phone={} 已被 cfc user(id={}) 占用,复用以绑定 openid={}", phone, phoneOwner.getId(), openid);
+                cfcUser = phoneOwner;
+            } else {
                 cfcUser.setPhone(phone);
                 cfcUser.setUpdatedAt(new Date());
-                cfcUserMapper.updateById(cfcUser);
+                try {
+                    cfcUserMapper.updateById(cfcUser);
+                } catch (DuplicateKeyException e) {
+                    // 并发兜底:另一请求同时写入同一 phone,回查复用
+                    log.warn("cfc user phone={} 写入冲突(并发),改为复用已存在 cfc user", phone);
+                    CfcUser concurrentOwner = cfcUserMapper.selectOne(
+                            new LambdaQueryWrapper<CfcUser>()
+                                    .eq(CfcUser::getPhone, phone)
+                                    .last("LIMIT 1"));
+                    if (concurrentOwner != null) {
+                        cfcUser = concurrentOwner;
+                    }
+                }
             }
         }
 
         TrainUser user = upsertTrainUser(openid, cfcUser);
         if (StringUtils.hasText(phone) && !phone.equals(user.getPhone())) {
             user.setPhone(phone);
-            trainUserMapper.updateById(user);
+            try {
+                trainUserMapper.updateById(user);
+            } catch (DuplicateKeyException e) {
+                // train_user.phone 若有唯一键(防御性),并发冲突时跳过,不阻塞登录
+                log.warn("train user phone={} 写入冲突(并发/唯一键),跳过 phone 更新", phone);
+            }
         }
 
         String token = jwtConfig.generateToken(user.getId(), user.getRole());

+ 5 - 19
train-frontend/App.vue

@@ -6,28 +6,14 @@ Vue.prototype.$config = config
 export default {
   onLaunch: function(options) {
     console.log('App Launch', options)
-    var token = uni.getStorageSync('token')
-    if (token) {
-      this.$store.commit('setToken', token)
-      var userInfo = uni.getStorageSync('userInfo')
-      if (userInfo) {
-        this.$store.commit('setUserInfo', userInfo)
-      }
-      var classId = uni.getStorageSync('classId')
-      if (classId) {
-        this.$store.commit('setClassId', classId)
-      }
-    }
+    // 从 storage 恢复登录态到 store,保证 state 与 storage 一致
+    this.$store.dispatch('restore')
   },
   onShow: function(options) {
     console.log('App Show', options)
-    var token = uni.getStorageSync('token')
-    if (token && token !== this.$store.state.token) {
-      this.$store.commit('setToken', token)
-      var userInfo = uni.getStorageSync('userInfo')
-      if (userInfo) {
-        this.$store.commit('setUserInfo', userInfo)
-      }
+    // 热启动兜底:若 store 空但 storage 有 token(如 HBuilderX 热更新后 store 重置),重新恢复
+    if (!this.$store.state.token && uni.getStorageSync('token')) {
+      this.$store.dispatch('restore')
     }
   },
   onHide: function() {

+ 0 - 7
train-frontend/pages.json

@@ -36,13 +36,6 @@
         "navigationBarTitleText": "我的"
       }
     },
-    {
-      "path": "pages/login/index",
-      "style": {
-        "navigationBarTitleText": "登录",
-        "navigationStyle": "custom"
-      }
-    },
     {
       "path": "pages/verify/index",
       "style": {

+ 3 - 1
train-frontend/pages/assignment/index.vue

@@ -31,6 +31,7 @@
 
 <script>
 import { claimAssignment, getMyAssignments } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -45,7 +46,8 @@ export default {
       claiming: { wealth: false, health: false, growth: false }
     }
   },
-  onShow() {
+onShow() {
+    if (!guardPage()) return
     this.loadAssignments()
   },
   methods: {

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

@@ -25,6 +25,7 @@
 
 <script>
 import { getMyCases, decideCase } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -35,6 +36,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadMine()
   },
   methods: {

+ 3 - 1
train-frontend/pages/checkin/index.vue

@@ -62,6 +62,7 @@
 
 <script>
 import { checkin, getCheckinStatus, prepCheck, getPrepCheckStatus, uploadFile } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -80,7 +81,8 @@ export default {
       prepLoading: false
     }
   },
-  onShow() {
+onShow() {
+    if (!guardPage()) return
     this.loadStatus()
   },
   methods: {

+ 2 - 0
train-frontend/pages/class/index.vue

@@ -32,6 +32,7 @@
 
 <script>
 import { joinClass, getClassInfo } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -43,6 +44,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadClassInfo()
   },
   methods: {

+ 18 - 8
train-frontend/pages/course/detail.vue

@@ -97,6 +97,7 @@
 <script>
 import { getMyCourse, getCourseProgress, enterCourse } from '@/utils/api.js'
 import { formatDateTime } from '@/utils/format.js'
+import { requireLogin } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -111,6 +112,9 @@ export default {
     }
   },
   computed: {
+    isLoggedIn() {
+      return this.$store.getters.isLoggedIn
+    },
     progressPercent() {
       var total = this.progress.total || 0
       if (!total) return '0%'
@@ -175,14 +179,17 @@ export default {
         self.classPlace = d.classPlace || ''
         self.enrollment = d.enrollment || null
       }).catch(function() {})
-      getCourseProgress().then(function(resp) {
-        var d = resp.data || {}
-        self.progress = {
-          done: d.done || 0,
-          total: d.total || 0,
-          items: d.items || []
-        }
-      }).catch(function() {})
+      // 学习进度需登录后请求,未登录浏览时不调用(避免 401 误触发登录过期弹窗)
+      if (self.isLoggedIn) {
+        getCourseProgress().then(function(resp) {
+          var d = resp.data || {}
+          self.progress = {
+            done: d.done || 0,
+            total: d.total || 0,
+            items: d.items || []
+          }
+        }).catch(function() {})
+      }
     },
     onMenu(g) {
       if (!g) return
@@ -194,6 +201,7 @@ export default {
     },
     applyEnter() {
       var self = this
+      if (!requireLogin()) return
       if (!self.course.id) return
       uni.showModal({
         title: '申请进班',
@@ -235,6 +243,8 @@ export default {
       return formatDateTime(v)
     },
     goTo(url) {
+      // 去报名/去支付为实际操作,未登录先引导登录(课程信息浏览已放行)
+      if (!requireLogin()) return
       uni.navigateTo({ url: url })
     }
   }

+ 4 - 1
train-frontend/pages/course/index.vue

@@ -41,6 +41,7 @@
 <script>
 import { getCourseList, enterCourse } from '@/utils/api.js'
 import { formatDateTime } from '@/utils/format.js'
+import { requireLogin } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -68,7 +69,7 @@ export default {
   },
   methods: {
     loadCourses() {
-      if (!this.isLoggedIn) return
+      // 未登录也可浏览课程信息(后端 /api/course/list 已放行,我的状态为 none)
       var self = this
       self.loading = true
       getCourseList().then(function(resp) {
@@ -101,6 +102,8 @@ export default {
     },
     handleAction(c) {
       if (!c) return
+      // 报名/支付/申请进班均为实际操作,未登录需先登录(课程浏览已放行)
+      if (!requireLogin()) return
       var s = c.myStatus
       if (s === 'confirmed') {
         uni.navigateTo({ url: '/pages/course/detail?courseId=' + c.id })

+ 2 - 0
train-frontend/pages/enroll/form.vue

@@ -56,6 +56,7 @@
 
 <script>
 import { createEnrollment } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -75,6 +76,7 @@ export default {
     }
   },
   onLoad(options) {
+    if (!guardPage()) return
     this.form.courseId = options && options.courseId ? options.courseId : ''
     this.courseName = options && options.courseName ? decodeURIComponent(options.courseName) : ''
     this.form.inviteCode = options && options.inviteCode ? decodeURIComponent(options.inviteCode) : ''

+ 2 - 0
train-frontend/pages/enroll/list.vue

@@ -63,6 +63,7 @@
 <script>
 import { getCourseList, getMyEnrollments, cancelPayOrder } from '@/utils/api.js'
 import { fenToYuan, formatDateTime } from '@/utils/format.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -74,6 +75,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadCourses()
     this.loadMine()
   },

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

@@ -64,6 +64,7 @@
 <script>
 import { getEnrollClasses, createGroupOrder } from '@/utils/api.js'
 import { fenToYuan } from '@/utils/format.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -85,6 +86,7 @@ export default {
     }
   },
   onLoad() {
+    if (!guardPage()) return
     this.loadClasses()
   },
   methods: {

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

@@ -58,6 +58,7 @@
 <script>
 import { getGroupOrderDetail, payGroupOrder } from '@/utils/api.js'
 import { fenToYuan, formatDateTime } from '@/utils/format.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -69,6 +70,7 @@ export default {
     }
   },
   onLoad(options) {
+    if (!guardPage()) return
     this.id = options && options.id ? options.id : ''
     if (!this.id) {
       uni.showToast({ title: '缺少团报单ID', icon: 'none' })

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

@@ -28,6 +28,7 @@
 <script>
 import { getMyGroupOrders } from '@/utils/api.js'
 import { fenToYuan, formatDateTime } from '@/utils/format.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -37,6 +38,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadMine()
   },
   methods: {

+ 3 - 1
train-frontend/pages/group/index.vue

@@ -35,6 +35,7 @@
 
 <script>
 import { getMyGroup, joinGroup, setSpokesperson } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -44,7 +45,8 @@ export default {
       loading: false
     }
   },
-  onShow() {
+onShow() {
+    if (!guardPage()) return
     this.loadGroup()
   },
   methods: {

+ 0 - 141
train-frontend/pages/login/index.vue

@@ -1,141 +0,0 @@
-<template>
-  <view class="login-page">
-    <view class="login-bg">
-      <view class="login-content">
-        <view class="logo-area">
-          <view class="logo-icon">🤖</view>
-      <text class="logo-title">爱伴·AI之旅</text>
-      <text class="logo-subtitle">L0 家立方-AI管家成长营</text>
-        </view>
-        <view class="login-btn-area">
-          <button
-            class="login-btn"
-            open-type="getPhoneNumber"
-            @getphonenumber="handleGetPhoneNumber"
-            :loading="loading"
-            :disabled="loading"
-          >
-            微信一键登录
-          </button>
-        </view>
-      </view>
-    </view>
-  </view>
-</template>
-
-<script>
-import { wechatPhoneLogin } from '@/utils/api.js'
-
-export default {
-  data() {
-    return {
-      loading: false
-    }
-  },
-  methods: {
-    // 微信手机号一键授权登录:getPhoneNumber 返回 phoneCode,同时用 uni.login 取 login code
-    handleGetPhoneNumber(e) {
-      if (this.loading) return
-      if (!e.detail || !e.detail.code) {
-        uni.showToast({ title: '授权失败,请重试', icon: 'none' })
-        return
-      }
-      // 手机号授权成功,实时获取 login code(微信 code 一次性有效,必须点击时取)
-      var self = this
-      self.loading = true
-      uni.login({
-        provider: 'weixin',
-        success: function(res) {
-          if (!res.code) {
-            uni.showToast({ title: '登录失败,请重试', icon: 'none' })
-            self.loading = false
-            return
-          }
-          wechatPhoneLogin({ code: res.code, phoneCode: e.detail.code }).then(function(resp) {
-            var data = resp.data
-            self.$store.dispatch('login', data)
-            uni.showToast({ title: '登录成功', icon: 'success' })
-            setTimeout(function() {
-              if (!data.alumniVerify || data.alumniVerify === 'pending') {
-                uni.redirectTo({ url: '/pages/verify/index' })
-              } else if (!data.classId) {
-                uni.switchTab({ url: '/pages/course/index' })
-              } else {
-                uni.switchTab({ url: '/pages/index/index' })
-              }
-            }, 800)
-          }).catch(function(err) {
-            console.error('登录失败', err)
-            uni.showToast({ title: (err && err.message) || '登录失败', icon: 'none' })
-          }).finally(function() {
-            self.loading = false
-          })
-        },
-        fail: function() {
-          uni.showToast({ title: '登录失败,请重试', icon: 'none' })
-          self.loading = false
-        }
-      })
-    }
-  }
-}
-</script>
-
-<style scoped>
-.login-page {
-  min-height: 100vh;
-  background: linear-gradient(135deg, #F97316, #EA580C);
-  display: flex;
-  flex-direction: column;
-}
-.login-bg {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-}
-.login-content {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: 0 60rpx;
-}
-.logo-area {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin-bottom: 120rpx;
-}
-.logo-icon {
-  font-size: 120rpx;
-  margin-bottom: 30rpx;
-}
-.logo-title {
-  font-size: 48rpx;
-  font-weight: 700;
-  color: #FFFFFF;
-  margin-bottom: 16rpx;
-}
-.logo-subtitle {
-  font-size: 28rpx;
-  color: rgba(255,255,255,0.85);
-}
-.login-btn-area {
-  width: 100%;
-}
-.login-btn {
-  width: 100%;
-  height: 96rpx;
-  line-height: 96rpx;
-  background: #FFFFFF;
-  color: #F97316;
-  font-size: 32rpx;
-  font-weight: 600;
-  border-radius: 48rpx;
-  border: none;
-}
-.login-btn:active {
-  opacity: 0.85;
-}
-</style>

+ 2 - 0
train-frontend/pages/material/index.vue

@@ -24,6 +24,7 @@
 
 <script>
 import { getClassMaterials } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -33,6 +34,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadMaterials()
   },
   methods: {

+ 16 - 2
train-frontend/pages/mine/index.vue

@@ -85,7 +85,8 @@
       <text class="course-entry-arrow">›</text>
     </view>
 
-    <button class="logout-btn" @click="handleLogout">退出登录</button>
+    <button v-if="isLoggedIn" class="logout-btn" @click="handleLogout">退出登录</button>
+    <button v-else class="logout-btn login-entry" @click="goLogin">点击登录</button>
   </view>
 </template>
 
@@ -102,6 +103,9 @@ export default {
     }
   },
   computed: {
+    isLoggedIn() {
+      return this.$store.getters.isLoggedIn
+    },
     name() {
       var stored = uni.getStorageSync('userInfo')
       var realName = this.$store.state.name || stored && stored.name || ''
@@ -133,10 +137,19 @@ export default {
     }
   },
   onShow() {
+    // 未登录不请求个人数据(卡券/证书接口需登录态,避免 401 误触发登录过期弹窗)
+    if (!this.isLoggedIn) return
     this.loadCert()
     this.loadCoupons()
   },
   methods: {
+    goLogin() {
+      // 未登录入口:回首页并触发登录浮层
+      uni.switchTab({ url: '/pages/index/index' })
+      setTimeout(function() {
+        uni.$emit('showLoginSheet', {})
+      }, 400)
+    },
     goTo(url) {
       uni.navigateTo({ url: url })
     },
@@ -201,7 +214,8 @@ export default {
         success: function(res) {
           if (res.confirm) {
             self.$store.commit('logout')
-            uni.reLaunch({ url: '/pages/login/index' })
+            // 登录页已下线,退出后回首页(首页展示点击登录入口)
+            uni.reLaunch({ url: '/pages/index/index' })
           }
         }
       })

+ 2 - 0
train-frontend/pages/pay/index.vue

@@ -25,6 +25,7 @@
 <script>
 import { createPayOrder, getPayStatus } from '@/utils/api.js'
 import { fenToYuan } from '@/utils/format.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -40,6 +41,7 @@ export default {
     }
   },
   onLoad(options) {
+    if (!guardPage()) return
     this.enrollmentId = options && options.enrollmentId ? options.enrollmentId : ''
     if (!this.enrollmentId) {
       uni.showToast({ title: '缺少报名信息', icon: 'none' })

+ 2 - 0
train-frontend/pages/pay/result.vue

@@ -17,6 +17,7 @@
 
 <script>
 import { fenToYuan } from '@/utils/format.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -28,6 +29,7 @@ export default {
     }
   },
   onLoad(options) {
+    if (!guardPage()) return
     this.status = options && options.status ? options.status : 'success'
     this.amount = options && options.amount ? Number(options.amount) : 0
     this.amountText = fenToYuan(this.amount)

+ 2 - 0
train-frontend/pages/plan/index.vue

@@ -26,6 +26,7 @@
 
 <script>
 import { submitPlan, getMyPlan } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -46,6 +47,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadPlan()
   },
   methods: {

+ 2 - 0
train-frontend/pages/roadmap/index.vue

@@ -20,6 +20,7 @@
 
 <script>
 import { uploadRoadmap, uploadFile } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -46,6 +47,7 @@ export default {
       })
     },
     handleSubmit() {
+      if (!guardPage()) return
       if (!this.proposition.trim()) {
         uni.showToast({ title: '请输入路演主题', icon: 'none' })
         return

+ 2 - 0
train-frontend/pages/scoreboard/index.vue

@@ -29,6 +29,7 @@
 
 <script>
 import { getScoreboard, getClassInfo } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -38,6 +39,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadData()
   },
   methods: {

+ 2 - 0
train-frontend/pages/share/index.vue

@@ -26,6 +26,7 @@
 
 <script>
 import { getMyPoster } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -37,6 +38,7 @@ export default {
     }
   },
   onLoad() {
+    if (!guardPage()) return
     this.loadPoster()
   },
   onReady() {

+ 2 - 0
train-frontend/pages/share/stats.vue

@@ -34,6 +34,7 @@
 
 <script>
 import { getInviteStats } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -42,6 +43,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadStats()
   },
   methods: {

+ 2 - 0
train-frontend/pages/survey/index.vue

@@ -34,6 +34,7 @@
 
 <script>
 import { submitSurvey, getMyCoupons } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -49,6 +50,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadCoupons()
   },
   methods: {

+ 2 - 0
train-frontend/pages/teaching/index.vue

@@ -28,6 +28,7 @@
 
 <script>
 import { submitTeachingCard, uploadFile } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -67,6 +68,7 @@ export default {
       })
     },
     handleSubmit() {
+      if (!guardPage()) return
       if (!this.form.steps.trim()) {
         uni.showToast({ title: '请填写教学步骤', icon: 'none' })
         return

+ 3 - 1
train-frontend/pages/upload/index.vue

@@ -42,6 +42,7 @@
 
 <script>
 import { uploadSubmission, getMySubmissions, uploadFile } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -67,7 +68,8 @@ export default {
       return this.bookTypes.find(b => b.value === this.uploadForm.bookType) || this.bookTypes[0]
     }
   },
-  onShow() {
+onShow() {
+    if (!guardPage()) return
     this.loadSubmissions()
   },
   methods: {

+ 2 - 0
train-frontend/pages/verify/index.vue

@@ -47,6 +47,7 @@
 
 <script>
 import { alumniVerify } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -70,6 +71,7 @@ export default {
     }
   },
   onLoad() {
+    if (!guardPage()) return
     // 登录时微信手机号一键授权获取的号码,自动预填,免手输
     var phone = this.$store.state.phone || uni.getStorageSync('userInfo') && uni.getStorageSync('userInfo').phone || ''
     if (phone) {

+ 2 - 0
train-frontend/pages/vote/index.vue

@@ -38,6 +38,7 @@
 
 <script>
 import { castVote, getVoteResult, getVotedStatus, getMyGroup, getClassGroups } from '@/utils/api.js'
+import { guardPage } from '@/utils/guard.js'
 
 export default {
   data() {
@@ -53,6 +54,7 @@ export default {
     }
   },
   onShow() {
+    if (!guardPage()) return
     this.loadGroup()
   },
   methods: {

+ 17 - 1
train-frontend/store/index.js

@@ -16,8 +16,10 @@ const store = new Vuex.Store({
     newUser: false
   },
   getters: {
+    // 单一可信源:优先 store,store 空时兜底读 storage(热启动/HBuilderX 热更新场景 store 可能未恢复)
     isLoggedIn: function(state) {
-      return !!state.token
+      if (state.token) return true
+      return !!uni.getStorageSync('token')
     },
     isVerified: function(state) {
       return state.alumniVerify === 'accepted'
@@ -96,6 +98,20 @@ const store = new Vuex.Store({
         phone: data.phone,
         newUser: data.newUser
       })
+    },
+    // 冷启动/热更新后从 storage 恢复登录态到 store,保证 state 与 storage 一致
+    restore({ commit }) {
+      var token = uni.getStorageSync('token')
+      if (!token) return
+      commit('setToken', token)
+      var userInfo = uni.getStorageSync('userInfo')
+      if (userInfo) {
+        commit('setUserInfo', userInfo)
+      }
+      var classId = uni.getStorageSync('classId')
+      if (classId) {
+        commit('setClassId', classId)
+      }
     }
   }
 })

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

@@ -0,0 +1,22 @@
+/**
+ * 登录守卫工具(三端通用:train-frontend 小程序)
+ *
+ * 需求:登录前可浏览课程信息,但班级/报名/投票/打卡/支付等实际操作必须登录。
+ * 未登录触发 requireLogin() 时:toast 提示 + 跳回首页 tab + 触发登录浮层(showLoginSheet)。
+ */
+export function requireLogin() {
+  if (getApp() && getApp().$store && getApp().$store.getters.isLoggedIn) return true
+  uni.showToast({ title: '请先登录', icon: 'none' })
+  setTimeout(function() {
+    uni.switchTab({ url: '/pages/index/index' })
+  }, 600)
+  setTimeout(function() {
+    uni.$emit('showLoginSheet', {})
+  }, 800)
+  return false
+}
+
+/** 页面级守卫:在 onShow/onLoad 首行调用,未登录返回 false 阻止后续加载 */
+export function guardPage() {
+  return requireLogin()
+}