“mitao”
2026-04-08 62f8d49c054823813dd2f5fa7dd1aee12b7cd84c
feat:App首页-Banner
3个文件已添加
8个文件已修改
463 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppWechatAuthClient.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/controller/AppHomeBannerController.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/service/AppHomeBannerService.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/home/vo/AppHomeBannerVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/statistics/controller/AppUserEntryLogController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/user/controller/AppUserFeedbackController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/service.js 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/request.js 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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 || ''
}
/**