tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java
@@ -18,6 +18,28 @@ } /** * 判断当前请求是否存在 APP 登录态。 */ public boolean isLogin() { return StpAppUserUtil.isLogin(); } /** * 获取当前 APP 登录用户 ID。 */ public Long getLoginUserId() { return StpAppUserUtil.getLoginIdAsLong(); } /** * 获取当前 APP 登录用户 ID,不存在时抛出未登录异常。 */ public Long requireLoginUserId() { StpAppUserUtil.checkLogin(); return StpAppUserUtil.getLoginIdAsLong(); } /** * 获取当前 APP 登录态的 token 信息。 */ public SaTokenInfo getTokenInfo() { tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppWechatAuthClient.java
@@ -6,6 +6,7 @@ import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.client.RestOperations; import org.springframework.web.client.RestTemplate; @@ -26,12 +27,14 @@ /** * 使用默认 HTTP 客户端构造微信认证客户端。 */ @Autowired public AppWechatAuthClient(AppWechatAuthProperties properties, ObjectMapper objectMapper) { this(properties, objectMapper, new RestTemplate()); } /** * 注入微信配置、JSON 解析器与 HTTP 客户端。 * 存在多个构造器时,这个扩展构造器仅供测试或内部定制使用,避免 Spring 误判默认注入入口。 */ AppWechatAuthClient(AppWechatAuthProperties properties, ObjectMapper objectMapper, tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/controller/AppHomeBannerController.java
New file @@ -0,0 +1,37 @@ package com.tcm.training.admin.app.home.controller; import com.tcm.training.admin.app.home.service.AppHomeBannerService; import com.tcm.training.admin.app.home.vo.AppHomeBannerVO; import com.tcm.training.admin.common.ApiResponse; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; /** * APP 首页 banner 接口。 */ @Validated @RestController @RequestMapping({"/api/app/home", "/api/v1/app/home"}) public class AppHomeBannerController { private final AppHomeBannerService appHomeBannerService; /** * 注入 APP 首页 banner 服务。 */ public AppHomeBannerController(AppHomeBannerService appHomeBannerService) { this.appHomeBannerService = appHomeBannerService; } /** * 获取 APP 首页顶部 banner 列表。 */ @GetMapping("/banners") public ApiResponse<List<AppHomeBannerVO>> listBanners() { return ApiResponse.ok(appHomeBannerService.listEnabledBanners()); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/service/AppHomeBannerService.java
New file @@ -0,0 +1,59 @@ package com.tcm.training.admin.app.home.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.tcm.training.admin.app.home.vo.AppHomeBannerVO; import com.tcm.training.admin.system.entity.SystemBannerConfig; import com.tcm.training.admin.system.mapper.SystemBannerConfigMapper; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; /** * APP 首页 banner 服务。 */ @Service public class AppHomeBannerService { private final SystemBannerConfigMapper systemBannerConfigMapper; /** * 注入 banner 配置查询能力。 */ public AppHomeBannerService(SystemBannerConfigMapper systemBannerConfigMapper) { this.systemBannerConfigMapper = systemBannerConfigMapper; } /** * 查询 APP 首页可展示的 banner 列表。 */ public List<AppHomeBannerVO> listEnabledBanners() { LocalDateTime now = LocalDateTime.now(); List<SystemBannerConfig> records = systemBannerConfigMapper.selectList(new LambdaQueryWrapper<SystemBannerConfig>() .eq(SystemBannerConfig::getDeleted, 0) .eq(SystemBannerConfig::getPublishEnabled, 1) .le(SystemBannerConfig::getStartAt, now) .ge(SystemBannerConfig::getEndAt, now) .orderByDesc(SystemBannerConfig::getId)); List<AppHomeBannerVO> result = new ArrayList<AppHomeBannerVO>(records.size()); for (SystemBannerConfig record : records) { result.add(toBannerVO(record)); } return result; } /** * 将后台 banner 配置转换为 APP 首页展示对象。 */ private AppHomeBannerVO toBannerVO(SystemBannerConfig record) { AppHomeBannerVO vo = new AppHomeBannerVO(); vo.setId(record.getId()); vo.setImageUrl(record.getBannerImg()); vo.setTitle(record.getTitle()); vo.setJumpType(record.getJumpType()); vo.setTargetId(record.getTargetId()); vo.setTargetUrl(record.getTargetUrl()); return vo; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/vo/AppHomeBannerVO.java
New file @@ -0,0 +1,24 @@ package com.tcm.training.admin.app.home.vo; import lombok.Getter; import lombok.Setter; /** * APP 首页顶部 banner 响应。 */ @Getter @Setter public class AppHomeBannerVO { private Long id; private String imageUrl; private String title; private String jumpType; private Long targetId; private String targetUrl; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/statistics/controller/AppUserEntryLogController.java
@@ -1,6 +1,6 @@ package com.tcm.training.admin.app.statistics.controller; import com.tcm.training.admin.app.auth.support.StpAppUserUtil; import com.tcm.training.admin.app.auth.service.AppAuthSessionService; import com.tcm.training.admin.common.ApiResponse; import com.tcm.training.admin.statistics.service.AppUserEntryLogAppService; import org.springframework.validation.annotation.Validated; @@ -17,20 +17,26 @@ public class AppUserEntryLogController { private final AppUserEntryLogAppService appUserEntryLogAppService; private final AppAuthSessionService appAuthSessionService; /** * 注入 APP 用户进入记录服务。 */ public AppUserEntryLogController(AppUserEntryLogAppService appUserEntryLogAppService) { public AppUserEntryLogController(AppUserEntryLogAppService appUserEntryLogAppService, AppAuthSessionService appAuthSessionService) { this.appUserEntryLogAppService = appUserEntryLogAppService; this.appAuthSessionService = appAuthSessionService; } /** * 记录 APP 用户进入行为。 * 记录 APP 用户进入行为,游客访问时静默跳过。 */ @PostMapping("/user-entry-logs") public ApiResponse<Void> record() { appUserEntryLogAppService.record(StpAppUserUtil.getLoginIdAsLong()); if (!appAuthSessionService.isLogin()) { return ApiResponse.ok(null); } appUserEntryLogAppService.record(appAuthSessionService.getLoginUserId()); return ApiResponse.ok(null); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/controller/AppUserFeedbackController.java
@@ -1,6 +1,6 @@ package com.tcm.training.admin.app.user.controller; import com.tcm.training.admin.app.auth.support.StpAppUserUtil; import com.tcm.training.admin.app.auth.service.AppAuthSessionService; import com.tcm.training.admin.common.ApiResponse; import com.tcm.training.admin.system.dto.UserFeedbackSubmitRequest; import com.tcm.training.admin.system.service.UserFeedbackAppService; @@ -21,20 +21,23 @@ public class AppUserFeedbackController { private final UserFeedbackAppService userFeedbackAppService; private final AppAuthSessionService appAuthSessionService; /** * 注入 APP 用户反馈服务。 */ public AppUserFeedbackController(UserFeedbackAppService userFeedbackAppService) { public AppUserFeedbackController(UserFeedbackAppService userFeedbackAppService, AppAuthSessionService appAuthSessionService) { this.userFeedbackAppService = userFeedbackAppService; this.appAuthSessionService = appAuthSessionService; } /** * 提交 APP 用户意见反馈。 * 提交 APP 用户意见反馈,游客访问时按全局规则返回 401。 */ @PostMapping("/user-feedbacks") public ApiResponse<Void> submit(@Valid @RequestBody UserFeedbackSubmitRequest request) { userFeedbackAppService.submit(StpAppUserUtil.getLoginIdAsLong(), request); userFeedbackAppService.submit(appAuthSessionService.requireLoginUserId(), request); return ApiResponse.ok(null); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java
@@ -52,10 +52,7 @@ } String requestPath = SaHolder.getRequest().getRequestPath(); if (isAppApi(requestPath)) { if (isPublicAppApi(requestPath)) { return; } StpAppUserUtil.checkLogin(); // APP 端允许游客访问,只有真正需要用户身份的接口才在业务层显式要求登录。 return; } if (isPublicAdminApi(requestPath)) { @@ -69,18 +66,6 @@ */ private boolean isAppApi(String requestPath) { return requestPath.startsWith("/api/app/") || requestPath.startsWith("/api/v1/app/"); } /** * 判断当前 APP 路由是否允许匿名访问。 */ private boolean isPublicAppApi(String requestPath) { return requestPath.startsWith("/api/app/auth/") || requestPath.startsWith("/api/v1/app/auth/") || requestPath.startsWith("/api/app/register/") || requestPath.startsWith("/api/v1/app/register/") || "/api/app/version/check".equals(requestPath) || "/api/v1/app/version/check".equals(requestPath); } /** traditionalApp/pages/home/home.vue
@@ -26,17 +26,13 @@ </view> <!-- Banners --> <view class="banner-section"> <view class="banner-main"> <image src="/static/image/home/live.png" mode="aspectFill" class="banner-img"></image> <!-- Carousel dots --> <view v-if="bannerList.length" class="banner-section"> <u-swiper :list="bannerList" :height="280" mode="none" name="imageUrl" bgColor="#fff" border-radius="20" :interval="3000" @change="handleBannerChange" @click="handleBannerClick"></u-swiper> <view class="dots"> <view class="dot active"></view> <image src="/static/image/home/circle.png" class="dot-img"></image> <image src="/static/image/home/circle.png" class="dot-img"></image> <view v-for="(item, index) in bannerList" :key="item.id || index" class="dot" :class="{ active: bannerActiveIndex === index }"></view> </view> </view> <image src="/static/image/home/AI.png" mode="aspectFill" class="banner-sub"></image> </view> <!-- Live Section --> @@ -134,13 +130,28 @@ <script setup lang="ts"> import UpdatePopup from "@/components/updatePopup.vue"; import { ref, onMounted, computed, } from "vue"; import { onPageScroll } from "@dcloudio/uni-app"; import { ref, onMounted, computed } from "vue"; import { onPageScroll, onShow } from "@dcloudio/uni-app"; import { getBannerList } from "./service"; interface HomeBannerItem { id: number | string; imageUrl: string; title?: string; jumpType?: string; targetId?: number | string | null; targetUrl?: string; } const statusBarHeight = ref(0); const scrollY = ref(0); // Add scrollY reactive variable const showUpdatePopup = ref(false); // Update popup visibility, set to true by default for immediate display const scrollY = ref(0); const showUpdatePopup = ref(false); const bannerList = ref<HomeBannerItem[]>([]); const bannerActiveIndex = ref(0); /** * 初始化导航栏所需系统信息。 */ onMounted(() => { uni.getSystemInfo({ success: (res) => { @@ -149,47 +160,159 @@ }); }); onPageScroll((e) => { scrollY.value = e.scrollTop; // Update scrollY on page scroll /** * 每次进入首页都刷新 banner,确保拿到后台最新上下架结果。 */ onShow(() => { loadBannerList(); }); /** * 监听页面滚动以驱动导航栏渐变。 */ onPageScroll((e) => { scrollY.value = e.scrollTop; }); /** * 计算自定义导航栏占位高度。 */ const navbarHeightPx = computed(() => { const navContentHeightRpx = 88; // Height of nav-content in rpx const navContentHeightRpx = 88; const screenWidthPx = uni.getSystemInfoSync().screenWidth; const navContentHeightPx = (navContentHeightRpx / 750) * screenWidthPx; return statusBarHeight.value + navContentHeightPx; }); /** * 根据滚动距离切换导航栏背景色。 */ const navbarBackgroundColor = computed(() => { // Change to white after scrolling down 50rpx const thresholdRpx = 50; const screenWidthPx = uni.getSystemInfoSync().screenWidth; const thresholdPx = (thresholdRpx / 750) * screenWidthPx; const color = scrollY.value > thresholdPx ? 'white' : 'transparent'; return color; return scrollY.value > thresholdPx ? 'white' : 'transparent'; }); /** * 请求首页顶部 banner 列表。 */ const loadBannerList = async () => { try { const res = await getBannerList(); if (res?.code !== 0 || !Array.isArray(res.data)) { bannerList.value = []; bannerActiveIndex.value = 0; return; } bannerList.value = res.data.filter((item: HomeBannerItem) => !!item?.imageUrl); bannerActiveIndex.value = 0; } catch { bannerList.value = []; bannerActiveIndex.value = 0; } }; /** * 同步当前轮播索引,用于自定义指示器展示。 */ const handleBannerChange = (index: number) => { bannerActiveIndex.value = index; }; /** * 根据 banner 跳转类型分发到现有页面。 */ const handleBannerClick = (index: number) => { const currentBanner = bannerList.value[index]; if (!currentBanner) { return; } // 课程和直播必须携带目标主键,避免跳进空白详情页。 if (currentBanner.jumpType === 'HEALTH_COURSE' || currentBanner.jumpType === 'LIVE_COURSE') { if (!currentBanner.targetId) { return; } } if (currentBanner.jumpType === 'HEALTH_COURSE') { uni.navigateTo({ url: `/pages/courseDetail/courseDetail?id=${currentBanner.targetId}` }); return; } if (currentBanner.jumpType === 'LIVE_COURSE') { uni.navigateTo({ url: `/pages/liveDetail/liveDetail?id=${currentBanner.targetId}` }); return; } if (currentBanner.jumpType === 'MEMBER') { uni.navigateTo({ url: '/pages/vip/vip' }); return; } if (currentBanner.jumpType === 'THIRD_PARTY_URL') { openThirdPartyUrl(currentBanner.targetUrl); } }; /** * 打开第三方链接。 */ const openThirdPartyUrl = (targetUrl?: string) => { if (!targetUrl) { return; } // APP 端已适配第三方链接时,直接交由系统浏览器打开。 // #ifdef APP-PLUS plus.runtime.openURL(targetUrl); return; // #endif // #ifdef H5 window.open(targetUrl, '_blank'); return; // #endif }; /** * 打开更新弹窗。 */ const handleOpenUpdate = () => { showUpdatePopup.value = true; }; /** * 关闭更新弹窗。 */ const closeUpdatePopup = () => { showUpdatePopup.value = false; }; /** * 确认更新后关闭弹窗。 */ const handleUpdateConfirm = () => { console.log('User confirmed update'); // Handle update logic here closeUpdatePopup(); }; /** * 跳转直播详情页。 */ const goLiveDetail = () => { uni.navigateTo({ url: '/pages/liveDetail/liveDetail' }); }; /** * 跳转搜索页。 */ const goSearch = () => { uni.navigateTo({ url: '/pages/search/search' @@ -320,18 +443,7 @@ .banner-section { margin: 0 24rpx; margin-top: 32rpx; .banner-main { position: relative; width: 702rpx; height: 280rpx; border-radius: 20rpx; overflow: hidden; .banner-img { width: 100%; height: 100%; } .dots { position: absolute; @@ -341,29 +453,21 @@ display: flex; justify-content: center; align-items: center; pointer-events: none; .dot { width: 24rpx; height: 10rpx; background: #FFF; border-radius: 6rpx; margin: 0 6rpx; } .dot-img { width: 10rpx; height: 10rpx; background: rgba(255, 255, 255, 0.5); border-radius: 10rpx; margin: 0 6rpx; opacity: 0.5; } } transition: all 0.3s ease; } .banner-sub { width: 702rpx; height: 160rpx; margin-top: 20rpx; border-radius: 16rpx; .dot.active { width: 24rpx; background: #FFF; } } } traditionalApp/pages/home/service.js
@@ -1,17 +1,22 @@ import request from '@/utils/request.js' //首页模块--轮播图 export const getBannerList = (params) => { return request.get(`/index/getBannerList`, params, 1) /** * 获取 APP 首页顶部 Banner 列表。 */ export const getBannerList = () => { return request.get('/api/app/home/banners', {}, false, false) } //获取攻略列表 /** * 获取首页攻略列表。 */ export const getGuideList = (params) => { return request.get(`/index/getGuideList`, params, 1) } //获取公告列表 /** * 获取首页公告列表。 */ export const getNoticeList = (params) => { return request.get(`/index/getNoticeList`, params, 1) } traditionalApp/utils/request.js
@@ -8,6 +8,7 @@ const waitingList = [] //等待队列 const excutingList = [] //执行队列 let appAuthRedirecting = false /** * 判断当前响应码是否属于成功态。 @@ -80,11 +81,7 @@ return } if (payload.code == 401 || payload.code == 510 || payload.code == 504 || payload.code == 505) { // uni.clearStorageSync() // handleLogout('登录过期,请重新登录') // uni.navigateTo({ // url: '/pages/login/login' // }) handleUnauthorized(payload) resolve(payload) return } @@ -135,29 +132,51 @@ } /** * 被冻结时跳转到登录页并取消后续请求。 * 收到未登录响应时清理 APP 登录态并跳转登录页。 */ function handleLogout(str) { let routeStr = getCurrentPages()[getCurrentPages().length - 1].route if (uni.getStorageSync('showLogoutModal') == 1) { uni.setStorageSync('showLogoutModal', 0) uni.showModal({ title: '提示', content: str, showCancel: false, success: function(res) { if (res.confirm) { if (routeStr == 'pages/login/login') { function handleUnauthorized(payload) { const routeStr = getCurrentRoute() clearLoginSession() clearAllRequest() if (routeStr === 'pages/login/login' || appAuthRedirecting) { return } appAuthRedirecting = true uni.showToast({ title: resolveMessage(payload) || '请先登录', duration: 1500, icon: 'none', mask: true, }) setTimeout(() => { uni.reLaunch({ url: '/pages/login/login' }) setTimeout(() => { appAuthRedirecting = false }, 300) }, 150) } /** * 清除 APP 登录态缓存。 */ function clearLoginSession() { uni.removeStorageSync('token') uni.removeStorageSync('appUserInfo') uni.removeStorageSync('loginRedirectMeta') uni.setStorageSync('showLogoutModal', 0) } }); clearAllRequest() /** * 获取当前页面路由,便于避免在登录页重复跳转。 */ function getCurrentRoute() { const currentPages = getCurrentPages() if (!currentPages || currentPages.length === 0) { return '' } return currentPages[currentPages.length - 1].route || '' } /**