longz
2026-07-03 ee54919d10dea640af38b8b3873710bca356d82a
部分优化
3个文件已添加
26个文件已修改
1231 ■■■■■ 已修改文件
.tmp_watchlive_frames/video.html 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAuthProperties.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/detail.tsx 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/index.tsx 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health/manage.tsx 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/manifest.json 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/articleDetail/articleDetail.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/articleDetail/service.js 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/courseDetail/courseDetail.vue 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/courseDetail/service.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/service.js 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/liveDetail/liveDetail.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/liveDetail/service.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/login.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/mine.vue 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/protocol/protocol.vue 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/protocol/service.js 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLive/watchLive.vue 412 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/public-request.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/push.js 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/request.js 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.tmp_watchlive_frames/video.html
New file
@@ -0,0 +1 @@
<!doctype html><html><body style="margin:0;background:#111"><video id="v" src="file:///C:/Users/PC/Videos/%E5%B1%8F%E5%B9%95%E5%BD%95%E5%88%B6/%E5%B1%8F%E5%B9%95%E5%BD%95%E5%88%B6%202026-06-23%20201151.mp4" controls style="width:100vw;height:100vh;object-fit:contain"></video></body></html>
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAuthProperties.java
New file
@@ -0,0 +1,31 @@
package com.tcm.training.admin.app.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * APP 登录认证配置。
 */
@Component
@ConfigurationProperties(prefix = "app.auth")
public class AppAuthProperties {
    /**
     * APP 登录 token 有效期,默认 30 天;设置为 -1 时表示永久有效。
     */
    private long tokenTimeoutSeconds = 30L * 24 * 60 * 60;
    /**
     * 获取 APP 登录 token 有效期秒数。
     */
    public long getTokenTimeoutSeconds() {
        return tokenTimeoutSeconds;
    }
    /**
     * 设置 APP 登录 token 有效期秒数。
     */
    public void setTokenTimeoutSeconds(long tokenTimeoutSeconds) {
        this.tokenTimeoutSeconds = tokenTimeoutSeconds;
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java
@@ -1,5 +1,6 @@
package com.tcm.training.admin.app.auth.service;
import com.tcm.training.admin.app.auth.config.AppAuthProperties;
import com.tcm.training.admin.app.auth.support.StpAppUserUtil;
import cn.dev33.satoken.stp.SaTokenInfo;
import org.springframework.stereotype.Service;
@@ -10,11 +11,17 @@
@Service
public class AppAuthSessionService {
    private final AppAuthProperties appAuthProperties;
    public AppAuthSessionService(AppAuthProperties appAuthProperties) {
        this.appAuthProperties = appAuthProperties;
    }
    /**
     * 为指定 APP 用户建立永久登录态。
     * 为指定 APP 用户建立登录态。
     */
    public void login(Long userId) {
        StpAppUserUtil.login(userId);
        StpAppUserUtil.login(userId, appAuthProperties.getTokenTimeoutSeconds());
    }
    /**
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java
@@ -10,16 +10,14 @@
public final class StpAppUserUtil {
    private static final StpLogic STP_LOGIC = new StpLogic("app-user");
    private static final long NEVER_EXPIRE_TIMEOUT = -1L;
    private StpAppUserUtil() {
    }
    /**
     * 为 APP 用户建立永久登录态。
     * 为 APP 用户建立指定有效期的登录态。
     */
    public static void login(Object loginId) {
        STP_LOGIC.login(loginId, new SaLoginModel().setTimeout(NEVER_EXPIRE_TIMEOUT));
    public static void login(Object loginId, long timeoutSeconds) {
        STP_LOGIC.login(loginId, new SaLoginModel().setTimeout(timeoutSeconds));
    }
    /**
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java
@@ -58,4 +58,6 @@
    @NotNull(message = "请选择是否首页推荐")
    private Integer isHomeRecommend;
    private Integer sort;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
@@ -120,7 +120,6 @@
        course.setFavoriteCount(0);
        course.setTotalDurationSeconds(0);
        course.setVideoCount(0);
        course.setSort(0);
        course.setDeleted(0);
        healthCourseMapper.insert(course);
        return detail(course.getId());
@@ -421,6 +420,7 @@
        course.setBaseSalesCount(req.getBaseSalesCount() == null ? 0 : req.getBaseSalesCount());
        course.setStatus(req.getStatus());
        course.setIsHomeRecommend(req.getIsHomeRecommend());
        course.setSort(defaultNumber(req.getSort()));
    }
    private void fillVideo(HealthCourseVideo video, HealthCourseVideoSaveRequest req) {
@@ -596,6 +596,7 @@
        vo.setCoverImg(row.getCoverImg());
        vo.setStatus(row.getStatus());
        vo.setIsHomeRecommend(row.getIsHomeRecommend());
        vo.setSort(defaultNumber(row.getSort()));
        vo.setCreatedAt(row.getCreatedAt());
        return vo;
    }
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java
@@ -24,5 +24,6 @@
    private String coverImg;
    private Integer status;
    private Integer isHomeRecommend;
    private Integer sort;
    private LocalDateTime createdAt;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java
@@ -27,5 +27,6 @@
    private String coverImg;
    private Integer status;
    private Integer isHomeRecommend;
    private Integer sort;
    private LocalDateTime createdAt;
}
tcm-training-admin-backend/src/main/resources/application.yml
@@ -79,6 +79,8 @@
    # 活动到期积分结算扫描间隔(毫秒)
    settlement-interval-ms: 60000
  auth:
    # APP 登录 token 有效期,默认 30 天;如需永久有效可改为 -1
    token-timeout-seconds: 2592000
    sms:
      # 短信验证码统一有效期 300 秒,即 5 分钟
      expire-seconds: 300
tcm-training-admin/src/pages/live/health/detail.tsx
@@ -30,6 +30,7 @@
  favoriteCount?: number;
  totalDurationSeconds?: number;
  status: number;
  sort?: number;
  expert?: {
    id: number;
    name: string;
@@ -273,6 +274,7 @@
              <div>课程介绍:{course?.summary || '-'}</div>
              <div>适用人群:{course?.targetUsers || '-'}</div>
              <div>状态:{course?.status === 1 ? '启用' : '禁用'}</div>
              <div>排序:{course?.sort ?? 0}</div>
              <div>基础购买量:{course?.baseSalesCount || 0}</div>
              <div>销量:{course?.salesCount || 0}</div>
              <div>收藏量:{course?.favoriteCount || 0}</div>
tcm-training-admin/src/pages/live/health/index.tsx
@@ -36,6 +36,7 @@
  favoriteCount?: number;
  coverImg?: string;
  status: number;
  sort?: number;
  expert?: {
    id: number;
    name: string;
@@ -210,6 +211,12 @@
      render: (value) => value?.name || '-',
    },
    {
      title: '排序',
      dataIndex: 'sort',
      width: 90,
      render: (value) => value ?? 0,
    },
    {
      title: '状态',
      dataIndex: 'status',
      width: 90,
tcm-training-admin/src/pages/live/health/manage.tsx
@@ -74,6 +74,7 @@
            isHomeRecommend: false,
            salePrice: 0,
            baseSalesCount: 0,
            sort: 0,
            summary: '',
            targetUsers: '',
            bannerImgs: [],
@@ -96,6 +97,7 @@
          coverImg: course.coverImg,
          bannerImgs: course.bannerImgs || [],
          baseSalesCount: course.baseSalesCount,
          sort: course.sort ?? 0,
          status: course.status === 1,
          isHomeRecommend: course.isHomeRecommend === 1,
          content: course.content,
@@ -117,6 +119,7 @@
      coverImg,
      bannerImgs,
      content: values.content,
      sort: Number(values.sort || 0),
      status: values.status ? 1 : 0,
      isHomeRecommend: values.isHomeRecommend ? 1 : 0,
    };
@@ -171,6 +174,9 @@
                disabled={loading}
              />
            </Form.Item>
            <Form.Item label="排序" name="sort">
              <InputNumber min={0} precision={0} placeholder="数值越小越靠前" className="health-course-edit-form__number" />
            </Form.Item>
            <div className="health-course-edit-form__switch-group">
              <div className="health-course-edit-form__switch-item">
                <span className="health-course-edit-form__switch-label">是否启用</span>
traditionalApp/manifest.json
@@ -1,10 +1,10 @@
{
    "sassImplementationName" : "node-sass",
    "name" : "海诊康道",
    "appid" : "__UNI__1A90541",
    "appid" : "__UNI__B2F54BE",
    "description" : "",
    "versionName" : "1.0.14",
    "versionCode" : 114,
    "versionName" : "1.0.18",
    "versionCode" : 118,
    "transformPx" : false,
    /* 5+App特有相关 */
    "app-plus" : {
traditionalApp/pages/articleDetail/articleDetail.vue
@@ -85,15 +85,35 @@
                statusBarHeight.value = res.statusBarHeight || 0
            }
        })
        setTimeout(() => {
            const pageOptions = getCurrentPageOptions()
            if (!popularizationId.value) {
                popularizationId.value = normalizeRouteParam(pageOptions?.id)
            }
            loadDetail()
        }, 0)
    })
    /**
     * 页面加载时读取科普主键并拉取详情接口。
     */
    onLoad((options) => {
        popularizationId.value = typeof options?.id === 'string' ? options.id : ''
        popularizationId.value = normalizeRouteParam(options?.id)
        loadDetail()
    })
    function normalizeRouteParam(value : unknown) {
        if (value === undefined || value === null) {
            return ''
        }
        return String(value).trim()
    }
    function getCurrentPageOptions() {
        const pages = getCurrentPages()
        const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined
        return currentPage?.options || currentPage?.$page?.options || {}
    }
    /**
     * 拉取健康科普详情接口并更新页面状态。
@@ -245,4 +265,4 @@
        line-height: 40rpx;
        color: #86909C;
    }
</style>
</style>
traditionalApp/pages/articleDetail/service.js
@@ -1,8 +1,8 @@
import request from '@/utils/request.js'
import { publicGet } from '@/utils/public-request.js'
/**
 * 获取 APP 健康科普详情。
 */
export const getHealthPopularizationDetail = (popularizationId) => {
    return request.get(`/api/app/health-popularizations/${popularizationId}`, {}, false, false)
    return publicGet(`/api/app/health-popularizations/${popularizationId}`)
}
traditionalApp/pages/courseDetail/courseDetail.vue
@@ -31,7 +31,7 @@
                    </swiper-item>
                </swiper>
                <view v-if="!isReadonlyDisplayMode && allVideos.length > 0" class="poster-play-btn"
                    @tap.stop="playLatestUploadedVideoFromPoster">
                    @tap.stop="playFirstCourseVideoFromPoster">
                    <text class="poster-play-icon">▶</text>
                </view>
                <!-- 图片指示器 -->
@@ -307,7 +307,7 @@
</template>
<script setup lang="ts">
    import { computed, getCurrentInstance, nextTick, ref } from 'vue'
    import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue'
    import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
    import config from '@/config/index.js'
    import {
@@ -417,6 +417,7 @@
    const activePurchaseVideoId = ref<number | null>(null)
    const courseId = ref('')
    const shouldPlayLatestVideoOnLoad = ref(false)
    const shouldPlayFirstVideoOnLoad = ref(false)
    const courseDetail = ref<CourseDetail>(createDefaultCourseDetail())
    const activeVideoId = ref<number>(0)
    const showVideoPlayer = ref(false)
@@ -541,14 +542,33 @@
    onLoad((options) => {
        courseId.value = resolveCourseId(options)
        shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(options, 'playLatest')
        shouldPlayFirstVideoOnLoad.value = resolveBooleanRouteParam(options, 'playFirst')
        loadCourseDetail()
    })
    onMounted(() => {
        setTimeout(() => {
            const pageOptions = getCurrentPageOptions()
            if (!courseId.value) {
                courseId.value = resolveCourseId(pageOptions)
                shouldPlayLatestVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playLatest')
                shouldPlayFirstVideoOnLoad.value = resolveBooleanRouteParam(pageOptions, 'playFirst')
            }
            loadCourseDetail()
        }, 0)
    })
    function getCurrentPageOptions() {
        const pages = getCurrentPages()
        const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined
        return currentPage?.options || currentPage?.$page?.options || {}
    }
    /**
     * 统一解析课程 id,兼容 App 路由参数和 H5 分享页地址参数。
     */
    function resolveCourseId(options ?: Record<string, unknown>) {
        const routeCourseId = typeof options?.id === 'string' ? options.id.trim() : ''
        const routeCourseId = normalizeOptionalText(options?.id)
        if (routeCourseId) {
            return routeCourseId
        }
@@ -666,6 +686,7 @@
                currentSwiperIndex.value = 0
            }
            syncActiveVideoAfterDetailLoad()
            handlePlayFirstVideoAfterDetailLoad()
            handlePlayLatestVideoAfterDetailLoad()
        } catch {
            showLoadFailedToast()
@@ -731,10 +752,21 @@
    }
    /**
     * 课程介绍封面播放按钮:播放最新上传章节。
     * 首页精选课程播放按钮要求直接播放第一个章节,详情接口返回后再执行。
     */
    function playLatestUploadedVideoFromPoster() {
        playLatestUploadedVideo()
    function handlePlayFirstVideoAfterDetailLoad() {
        if (!shouldPlayFirstVideoOnLoad.value) {
            return
        }
        shouldPlayFirstVideoOnLoad.value = false
        playFirstCourseVideo()
    }
    /**
     * 课程介绍封面播放按钮:播放第一个章节。
     */
    function playFirstCourseVideoFromPoster() {
        playFirstCourseVideo()
    }
    function playLatestUploadedVideo() {
@@ -754,6 +786,27 @@
        handlePaidVideoTap(latestVideo)
    }
    function playFirstCourseVideo() {
        const firstVideo = resolveFirstCourseVideo(allVideos.value)
        if (!firstVideo) {
            uni.showToast({
                title: '暂无可播放课程视频',
                icon: 'none',
            })
            return
        }
        currentTab.value = 'list'
        if (canPlayVideo(firstVideo)) {
            playSelectedVideo(firstVideo)
            return
        }
        handlePaidVideoTap(firstVideo)
    }
    function resolveFirstCourseVideo(videos : CourseVideo[]) {
        return videos.find((item) => !!item?.videoUrl) || null
    }
    /**
     * 优先按上传时间取最新章节;旧接口无时间字段时按自增 id 兜底。
     */
traditionalApp/pages/courseDetail/service.js
@@ -1,10 +1,11 @@
import request from '@/utils/request.js'
import { publicGet } from '@/utils/public-request.js'
/**
 * 获取 APP 养生课程详情。
 */
export const getHealthCourseDetail = (courseId) => {
    return request.get(`/api/app/health-courses/${courseId}`, {}, false, false)
    return publicGet(`/api/app/health-courses/${courseId}`)
}
/**
traditionalApp/pages/home/home.vue
@@ -4,16 +4,10 @@
            <image src="/static/image/home/bg.png" mode="widthFix" class="bg-img"></image>
        </view>
        <!-- Custom Navigation Bar -->
        <view class="custom-navbar" :style="{ backgroundColor: navbarBackgroundColor }">
            <view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view>
            <view class="nav-content">
                <view class="nav-title">海诊康道</view>
            </view>
        <view class="status-bar-placeholder" :style="{ height: statusBarHeight + 'px' }"></view>
        <view class="home-nav-bar">
            <view class="nav-title">海诊康道</view>
        </view>
        <!-- Spacer to push the content down below the fixed custom-navbar -->
        <view class="custom-navbar-spacer" :style="{ height: navbarHeightPx + 'px' }"></view>
        <view class="content">
            <!-- Search Bar -->
@@ -36,9 +30,13 @@
                </view>
            </view>
            <view class="AI" style="width: 702rpx;height: 160rpx;margin: 0 auto;margin-top: 26rpx;"
            <view class="health-test-entry" hover-class="health-test-entry--pressed" hover-stay-time="80"
                @tap="goMiniProgram">
                <image src="/static/image/home/AI.png" mode="aspectFill" class="w100 h100"></image>
                <image src="/static/image/home/AI.png" mode="aspectFill" class="health-test-entry__bg"></image>
                <view class="health-test-entry__cta">
                    <text>立即测一测</text>
                    <text class="health-test-entry__arrow">›</text>
                </view>
            </view>
@@ -81,7 +79,7 @@
                        <view class="course-cover-wrap">
                            <image :src="item.coverImage || defaultCourseCover" mode="aspectFill" class="course-img">
                            </image>
                            <view class="course-play-btn" @tap.stop="playLatestCourseVideo(item.id)">
                            <view class="course-play-btn" @tap.stop="playFirstCourseVideo(item.id)">
                                <text class="course-play-icon">▶</text>
                            </view>
                        </view>
@@ -116,8 +114,8 @@
</template>
<script setup lang="ts">
    import { ref, onMounted, computed } from "vue";
    import { onPageScroll, onPullDownRefresh, onShow } from "@dcloudio/uni-app";
    import { ref, onMounted } from "vue";
    import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
    import { getBannerList, getHomeFeaturedContent, getHomeLiveCourseList } from "./service";
    interface HomeBannerItem {
@@ -164,7 +162,6 @@
    }
    const statusBarHeight = ref(0);
    const scrollY = ref(0);
    const bannerList = ref<HomeBannerItem[]>([]);
    const bannerActiveIndex = ref(0);
    const liveCourseList = ref<HomeLiveCourseItem[]>([]);
@@ -184,6 +181,9 @@
                statusBarHeight.value = res.statusBarHeight || 0;
            },
        });
        setTimeout(() => {
            refreshHomeData();
        }, 0);
    });
    /**
@@ -202,33 +202,6 @@
        } finally {
            uni.stopPullDownRefresh();
        }
    });
    /**
     * 监听页面滚动以驱动导航栏渐变。
     */
    onPageScroll((e) => {
        scrollY.value = e.scrollTop;
    });
    /**
     * 计算自定义导航栏占位高度。
     */
    const navbarHeightPx = computed(() => {
        const navContentHeightRpx = 88;
        const screenWidthPx = uni.getSystemInfoSync().screenWidth;
        const navContentHeightPx = (navContentHeightRpx / 750) * screenWidthPx;
        return statusBarHeight.value + navContentHeightPx;
    });
    /**
     * 根据滚动距离切换导航栏背景色。
     */
    const navbarBackgroundColor = computed(() => {
        const thresholdRpx = 50;
        const screenWidthPx = uni.getSystemInfoSync().screenWidth;
        const thresholdPx = (thresholdRpx / 750) * screenWidthPx;
        return scrollY.value > thresholdPx ? 'white' : 'transparent';
    });
    /**
@@ -304,7 +277,8 @@
                liveCourseList.value = [];
                return;
            }
            liveCourseList.value = res.data.filter((item : HomeLiveCourseItem) => !!item?.courseId);
            const nextList = res.data.filter((item : HomeLiveCourseItem) => !!item?.courseId);
            liveCourseList.value = nextList;
        } catch {
            liveCourseList.value = [];
        }
@@ -322,12 +296,14 @@
                return;
            }
            const responseData = res.data as HomeFeaturedContentResponse;
            featuredHealthCourseList.value = Array.isArray(responseData.healthCourses)
            const nextCourses = Array.isArray(responseData.healthCourses)
                ? responseData.healthCourses.filter((item : HomeFeaturedHealthCourseItem) => !!item?.id)
                : [];
            featuredPopularizationList.value = Array.isArray(responseData.healthPopularizations)
            const nextPopularizations = Array.isArray(responseData.healthPopularizations)
                ? responseData.healthPopularizations.filter((item : HomeFeaturedPopularizationItem) => !!item?.id)
                : [];
            featuredHealthCourseList.value = nextCourses;
            featuredPopularizationList.value = nextPopularizations;
        } catch {
            featuredHealthCourseList.value = [];
            featuredPopularizationList.value = [];
@@ -343,7 +319,7 @@
        }
        refreshing.value = true;
        try {
            await Promise.all([
            await Promise.allSettled([
                loadBannerList(),
                loadHomeLiveCourseList(),
                loadFeaturedContent()
@@ -470,8 +446,9 @@
     * 跳转直播详情页。
     */
    const goLiveDetail = (courseId ?: number | string | null) => {
        const url = courseId ? `/pages/liveDetail/liveDetail?id=${courseId}` : '/pages/liveDetail/liveDetail';
        uni.navigateTo({
            url: courseId ? `/pages/liveDetail/liveDetail?id=${courseId}` : '/pages/liveDetail/liveDetail'
            url
        });
    };
@@ -479,20 +456,21 @@
     * 跳转养生课程详情页。
     */
    const goCourseDetail = (courseId ?: number | string | null) => {
        const url = courseId ? `/pages/courseDetail/courseDetail?id=${courseId}` : '/pages/courseDetail/courseDetail';
        uni.navigateTo({
            url: courseId ? `/pages/courseDetail/courseDetail?id=${courseId}` : '/pages/courseDetail/courseDetail'
            url
        });
    };
    /**
     * 从首页精选课程直接进入详情并播放最新上传章节。
     * 从首页精选课程直接进入详情并播放第一个章节。
     */
    const playLatestCourseVideo = (courseId ?: number | string | null) => {
    const playFirstCourseVideo = (courseId ?: number | string | null) => {
        if (!courseId) {
            return;
        }
        uni.navigateTo({
            url: `/pages/courseDetail/courseDetail?id=${courseId}&playLatest=1`
            url: `/pages/courseDetail/courseDetail?id=${courseId}&playFirst=1`
        });
    };
@@ -500,10 +478,11 @@
     * 跳转健康科普详情页。
     */
    const goPopularizationDetail = (popularizationId ?: number | string | null) => {
        const url = popularizationId
            ? `/pages/articleDetail/articleDetail?id=${popularizationId}`
            : '/pages/articleDetail/articleDetail';
        uni.navigateTo({
            url: popularizationId
                ? `/pages/articleDetail/articleDetail?id=${popularizationId}`
                : '/pages/articleDetail/articleDetail'
            url
        });
    };
@@ -550,33 +529,24 @@
        }
    }
    .custom-navbar {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        z-index: 100;
        transition: background-color 0.3s ease-in-out;
    }
    .status-bar-placeholder {
        width: 100%;
        /* height is set dynamically via style binding */
        position: relative;
        z-index: 10;
    }
    .nav-content {
    .home-nav-bar {
        display: flex;
        align-items: center;
        justify-content: center;
        height: 88rpx;
        /* Assuming 88rpx for the nav content part */
        width: 100%;
        background-color: transparent;
        /* Or specific color if needed */
        position: relative;
        z-index: 10;
        .nav-title {
            font-size: 36rpx;
            font-weight: bold;
            font-weight: 600;
            color: #333;
        }
    }
@@ -588,8 +558,6 @@
        z-index: 10;
        // padding: 0 24rpx;
        padding-top: 24rpx;
        margin-top: -30rpx;
        /* Re-added for overlap with top-bg */
    }
    /* Search Box */
@@ -668,6 +636,54 @@
        }
    }
    .health-test-entry {
        position: relative;
        width: 702rpx;
        height: 160rpx;
        margin: 26rpx auto 0;
        border-radius: 20rpx;
        overflow: hidden;
        background: #fff7de;
        border: 2rpx solid rgba(214, 151, 57, 0.22);
        box-shadow: 0 14rpx 28rpx rgba(156, 103, 31, 0.16);
        transform: translateZ(0);
        transition: transform 0.12s ease, box-shadow 0.12s ease;
    }
    .health-test-entry--pressed {
        transform: scale(0.985);
        box-shadow: 0 8rpx 18rpx rgba(156, 103, 31, 0.12);
    }
    .health-test-entry__bg {
        width: 702rpx;
        height: 160rpx;
        display: block;
    }
    .health-test-entry__cta {
        position: absolute;
        right: 24rpx;
        bottom: 22rpx;
        height: 48rpx;
        padding: 0 18rpx 0 22rpx;
        border-radius: 999rpx;
        background: linear-gradient(90deg, #D94436 0%, #BA241F 100%);
        color: #fff;
        font-size: 24rpx;
        font-weight: 600;
        display: flex;
        align-items: center;
        box-shadow: 0 8rpx 18rpx rgba(198, 53, 32, 0.28);
    }
    .health-test-entry__arrow {
        margin-left: 8rpx;
        font-size: 36rpx;
        line-height: 1;
        transform: translateY(-1rpx);
    }
    /* Sections */
    .section {
traditionalApp/pages/home/service.js
@@ -1,24 +1,32 @@
import request from '@/utils/request.js'
import { publicGet as rawPublicGet } from '@/utils/public-request.js'
/**
 * 首页是游客可见模块,公共接口不要经过登录态刷新、请求队列或鉴权拦截逻辑。
 */
function publicGet(url, data = {}) {
    return rawPublicGet(url, data)
}
/**
 * 获取 APP 首页顶部 Banner 列表。
 */
export const getBannerList = () => {
    return request.get('/api/app/home/banners', {}, false, false)
    return publicGet('/api/app/home/banners')
}
/**
 * 获取 APP 首页养生直播模块列表。
 */
export const getHomeLiveCourseList = () => {
    return request.get('/api/app/home/live-courses', {}, false, false)
    return publicGet('/api/app/home/live-courses')
}
/**
 * 获取 APP 首页精选课程介绍模块数据。
 */
export const getHomeFeaturedContent = () => {
    return request.get('/api/app/home/featured-content', {}, false, false)
    return publicGet('/api/app/home/featured-content')
}
/**
traditionalApp/pages/liveDetail/liveDetail.vue
@@ -26,9 +26,6 @@
                <image v-if="isWatchable" class="play-btn"
                    src="http://obs.haizhentong.com/file/play-bc5effa8a858450a8851f4304a083fb0.png" mode="aspectFit"
                    @tap="handlePrimaryAction"></image>
                <image v-if="isWatchable" class="fullscreen-icon"
                    src="http://obs.haizhentong.com/file/screen-d29662792d3f4890b5f094333feca7ca.png" mode="aspectFit"
                    @tap="goToWatchLive"></image>
            </view>
        </view>
@@ -101,7 +98,7 @@
</template>
<script setup lang="ts">
    import { computed, ref } from 'vue'
    import { computed, onMounted, ref } from 'vue'
    import { onLoad, onShow } from '@dcloudio/uni-app'
    import { getLiveCourseDetail, reserveLiveCourseSession } from './service'
    import config from '@/config/index.js'
@@ -294,13 +291,30 @@
     */
    onLoad((options) => {
        courseId.value = resolveCourseId(options)
        loadLiveDetail()
    })
    onMounted(() => {
        setTimeout(() => {
            const pageOptions = getCurrentPageOptions()
            if (!courseId.value) {
                courseId.value = resolveCourseId(pageOptions)
            }
            loadLiveDetail()
        }, 0)
    })
    function getCurrentPageOptions() {
        const pages = getCurrentPages()
        const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined
        return currentPage?.options || currentPage?.$page?.options || {}
    }
    /**
     * 统一解析直播课程 id,兼容 App 路由参数和 H5 分享地址参数。
     */
    function resolveCourseId(options ?: Record<string, unknown>) {
        const routeCourseId = typeof options?.id === 'string' ? options.id.trim() : ''
        const routeCourseId = normalizeRouteParam(options?.id)
        if (routeCourseId) {
            return routeCourseId
        }
@@ -317,6 +331,13 @@
        }
        // #endif
        return ''
    }
    function normalizeRouteParam(value : unknown) {
        if (value === undefined || value === null) {
            return ''
        }
        return String(value).trim()
    }
    /**
@@ -658,13 +679,6 @@
            height: 140rpx;
        }
        .fullscreen-icon {
            position: absolute;
            right: 24rpx;
            bottom: 24rpx;
            width: 48rpx;
            height: 48rpx;
        }
    }
    .scroll-content {
traditionalApp/pages/liveDetail/service.js
@@ -1,10 +1,11 @@
import request from '@/utils/request.js'
import { publicGet } from '@/utils/public-request.js'
/**
 * 获取 APP 养生直播详情。
 */
export const getLiveCourseDetail = (courseId) => {
    return request.get(`/api/app/live-courses/${courseId}`, {}, false, false)
    return publicGet(`/api/app/live-courses/${courseId}`)
}
/**
@@ -18,5 +19,5 @@
 * 获取 APP 养生直播专家详情。
 */
export const getLiveExpertDetail = (expertId) => {
    return request.get(`/api/app/live-experts/${expertId}`, {}, false, false)
    return publicGet(`/api/app/live-experts/${expertId}`)
}
traditionalApp/pages/login/login.vue
@@ -229,7 +229,7 @@
                })
                return
            }
            saveLoginSession(res.data)
            saveLoginSession(res.data, normalizedPhone)
            uni.showToast({
                title: '登录成功',
                icon: 'success'
@@ -608,11 +608,14 @@
    /**
     * 将登录响应写入本地缓存,供后续请求与页面读取。
     */
    const saveLoginSession = (loginData : AppLoginPayload) => {
    const saveLoginSession = (loginData : AppLoginPayload, fallbackPhone = '') => {
        if (!loginData) {
            return
        }
        saveAuthSession(loginData, {
            userOverrides: {
                phone: normalizeOptionalText(loginData.user?.phone) || fallbackPhone
            },
            redirectMeta: {
                inviteUserId: inviteUserId.value,
                inviteType: inviteType.value
traditionalApp/pages/mine/mine.vue
@@ -36,7 +36,7 @@
            </view>
            <view class="list-section card-box">
                <view class="list-item" v-for="(item, index) in bottomMenuList" :key="`${item.title}-${index}`" @tap="goPage(item.url)">
                <view class="list-item" v-for="(item, index) in bottomMenuList" :key="`${item.title}-${index}`" @tap="goPage(item)">
                    <view class="list-left">
                        <image class="list-icon" :class="item.iconClass" :src="item.icon" mode="aspectFit"></image>
                        <text class="list-title">{{ item.title }}</text>
@@ -56,6 +56,8 @@
import { getNoticeList } from '../notice/service'
import { getUserCenter } from './service'
import { getRuntimeVersionName } from '@/utils/app-version.js'
import { hasStoredSession } from '@/utils/auth-session.js'
import manifestConfig from '../../manifest.json'
type UserRole = 'personal' | 'doctor'
@@ -88,6 +90,7 @@
    icon: string
    url: string
    iconClass?: string
    requiresLogin?: boolean
}
interface NoticeSummaryItem {
@@ -96,13 +99,14 @@
const statusBarHeight = ref(0)
const unreadNoticeTotal = ref(0)
const appVersionText = ref('1.0.14')
const manifestVersionName = String(manifestConfig.versionName || '')
const appVersionText = ref(manifestVersionName)
const userCenter = ref<UserCenterData>(createDefaultUserCenter())
const userRole = computed<UserRole>(() => {
    return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal'
})
const displayName = computed(() => {
    return normalizeOptionalText(userCenter.value.nickname) || normalizeOptionalText(userCenter.value.phone) || '游客'
    return normalizeOptionalText(userCenter.value.nickname) || normalizeOptionalText(userCenter.value.phone) || (hasStoredSession() ? '已登录用户' : '游客')
})
const displayAvatar = computed(() => {
    return normalizeOptionalText(userCenter.value.avatar) || '/static/image/mine/header.png'
@@ -117,26 +121,26 @@
const gridMenuList = computed<MenuItem[]>(() => {
    if (userRole.value === 'doctor') {
        return [
            { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' },
            { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' },
            { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' },
            { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients' },
            { title: '诊所信息', icon: '/static/image/mine/hosptial.png', url: '/pages/clinicInfo/clinicInfo' },
            { title: '二维码', icon: '/static/image/mine/code.png', url: '/pages/inviteUser/inviteUser' },
            { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord', requiresLogin: true },
            { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites', requiresLogin: true },
            { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads', requiresLogin: true },
            { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients', requiresLogin: true },
            { title: '诊所信息', icon: '/static/image/mine/hosptial.png', url: '/pages/clinicInfo/clinicInfo', requiresLogin: true },
            { title: '二维码', icon: '/static/image/mine/code.png', url: '/pages/inviteUser/inviteUser', requiresLogin: true },
        ]
    }
    return [
        { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' },
        { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/exchangeRecord/exchangeRecord' },
        { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' },
        { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' },
        { title: '我的积分', icon: '/static/image/mine/point.png', url: '/pages/point/point' },
        { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord', requiresLogin: true },
        { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/exchangeRecord/exchangeRecord', requiresLogin: true },
        { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites', requiresLogin: true },
        { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads', requiresLogin: true },
        { title: '我的积分', icon: '/static/image/mine/point.png', url: '/pages/point/point', requiresLogin: true },
    ]
})
const bottomMenuList = computed<MenuItem[]>(() => {
    const baseList = [
        { title: '我的课程', icon: '/static/image/mine/myClass.png', url: '/pages/myCourses/myCourses', iconClass: 'icon-myclass' },
        { title: '我的课程', icon: '/static/image/mine/myClass.png', url: '/pages/myCourses/myCourses', iconClass: 'icon-myclass', requiresLogin: true },
        { title: '帮助中心', icon: '/static/image/mine/help.png', url: '/pages/helpCenter/helpCenter', iconClass: 'icon-help' },
        { title: '意见反馈', icon: '/static/image/mine/message.png', url: '/pages/feedback/feedback', iconClass: 'icon-feedback' },
    ]
@@ -253,13 +257,13 @@
 * 将接口或缓存中的用户信息归一化为页面可直接消费的结构。
 */
function normalizeUserCenter(value: unknown): UserCenterData {
    const source = (value && typeof value === 'object' ? value : {}) as Partial<UserCenterData>
    const source = (value && typeof value === 'object' ? value : {}) as Partial<UserCenterData> & Record<string, unknown>
    const type = Number(source.type)
    return {
        id: Number.isFinite(Number(source.id)) ? Number(source.id) : null,
        nickname: normalizeOptionalText(source.nickname),
        avatar: normalizeOptionalText(source.avatar),
        phone: normalizeOptionalText(source.phone),
        nickname: normalizeOptionalText(source.nickname) || normalizeOptionalText(source.nickName) || normalizeOptionalText(source.name),
        avatar: normalizeOptionalText(source.avatar) || normalizeOptionalText(source.headImgUrl),
        phone: normalizeOptionalText(source.phone) || normalizeOptionalText(source.mobile),
        type: Number.isFinite(type) ? type : 0,
        typeText: normalizeOptionalText(source.typeText),
        memberExpireAt: normalizeOptionalText(source.memberExpireAt),
@@ -295,14 +299,19 @@
    if (!item.url) {
        return
    }
    goPage(item.url)
    goPage(item)
}
/**
 * 统一跳转个人中心相关页面。
 */
function goPage(url: string) {
function goPage(target: string | MenuItem) {
    const url = typeof target === 'string' ? target : target.url
    if (!url) {
        return
    }
    if (typeof target !== 'string' && target.requiresLogin && !hasStoredSession()) {
        redirectToLogin()
        return
    }
    uni.navigateTo({
@@ -311,13 +320,28 @@
}
/**
 * 未登录访问个人资产类页面时,先进入登录页。
 */
function redirectToLogin() {
    uni.showToast({
        title: '请先登录',
        icon: 'none',
    })
    setTimeout(() => {
        uni.navigateTo({
            url: '/pages/login/login',
        })
    }, 200)
}
/**
 * APP 环境读取当前安装包版本,非 APP 环境使用 manifest 版本兜底。
 */
function resolveAppVersion() {
    getRuntimeVersionName('1.0.14').then((versionName) => {
        appVersionText.value = versionName || '1.0.14'
    getRuntimeVersionName(manifestVersionName).then((versionName) => {
        appVersionText.value = versionName || manifestVersionName
    }).catch(() => {
        appVersionText.value = '1.0.14'
        appVersionText.value = manifestVersionName
    })
}
@@ -375,7 +399,7 @@
    display: flex;
    justify-content: flex-end;
    align-items: center;
    padding-top: 20rpx;
    padding-top: 80rpx;
    gap: 32rpx;
    .notice-action {
@@ -410,7 +434,7 @@
.user-info {
    display: flex;
    align-items: center;
    margin-top: 40rpx;
    margin-top: 24rpx;
    .avatar-wrap {
        position: relative;
traditionalApp/pages/protocol/protocol.vue
@@ -11,151 +11,156 @@
                <rich-text :nodes="expertIntroNodes" class="section-rich-content"></rich-text>
            </view>
        </view>
        <rich-text v-else :nodes="htmlContent" class="rich-content"></rich-text>
        <view v-else class="protocol-content">
            <rich-text :nodes="htmlContent" class="rich-content"></rich-text>
        </view>
    </view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
<script>
import { getLiveExpertDetail } from '../liveDetail/service'
import { getAgreementContentDetail } from './service'
interface ExpertDetail {
    id?: number | string | null
    name: string
    avatar: string
    specialty: string
    intro: string
}
const DEFAULT_USER_AGREEMENT_TITLE = '用户服务协议'
const DEFAULT_PRIVACY_TITLE = '隐私协议'
const pageType = ref('')
const htmlContent = ref('')
const expertDetail = ref<ExpertDetail>(createFallbackExpertDetail())
const expertIntroNodes = ref(buildRichTextNodes(createFallbackExpertDetail().intro, '暂无专家介绍'))
export default {
    data() {
        return {
            pageType: '',
            htmlContent: '',
            expertDetail: this.createFallbackExpertDetail(),
            expertIntroNodes: this.buildRichTextNodes(this.createFallbackExpertDetail().intro, '暂无专家介绍')
        }
    },
    onLoad(options) {
        this.pageType = typeof options?.type === 'string' ? options.type : ''
/**
 * 创建专家介绍页的兜底静态数据,避免旧入口无参时页面空白。
 */
function createFallbackExpertDetail(): ExpertDetail {
    return {
        id: null,
        name: '张雨泽',
        avatar: '/static/image/home/avatr.png',
        specialty: '中医文化传播',
        intro: '专家老师详细介绍内容。张雨泽老师在相关领域拥有多年的丰富经验,致力于传播中医文化,培养优秀的专业人才。',
    }
}
/**
 * 加载协议详情页真实接口,未配置时返回空富文本兜底。
 */
async function loadAgreementContent(type: string) {
    uni.setNavigationBarTitle({
        title: resolveAgreementTitle(type),
    })
    htmlContent.value = buildRichTextNodes('', '暂无协议内容')
    try {
        const response = await getAgreementContentDetail(type)
        if (response?.code !== 0 || !response.data) {
        if (this.pageType === 'user') {
            this.loadAgreementContent(this.pageType)
            return
        }
        const agreementName = normalizeText(response.data.agreementName) || resolveAgreementTitle(type)
        uni.setNavigationBarTitle({
            title: agreementName,
        })
        htmlContent.value = buildRichTextNodes(normalizeText(response.data.contentHtml), '暂无协议内容')
    } catch {
        // 请求层会统一处理异常提示,这里保留兜底空内容即可。
    }
}
/**
 * 将接口返回的富文本正文转换为 `rich-text` 可直接渲染的节点字符串。
 */
function buildRichTextNodes(content?: string, fallbackText?: string): string {
    if (typeof content === 'string' && content.trim()) {
        return content
    }
    if (typeof fallbackText === 'string' && fallbackText.trim()) {
        return `<p>${fallbackText.trim()}</p>`
    }
    return '<p></p>'
}
/**
 * 根据页面类型返回协议页默认标题。
 */
function resolveAgreementTitle(type: string) {
    if (type === 'privacy') {
        return DEFAULT_PRIVACY_TITLE
    }
    return DEFAULT_USER_AGREEMENT_TITLE
}
/**
 * 将可选文本归一化为空字符串。
 */
function normalizeText(value: unknown) {
    if (typeof value !== 'string') {
        return ''
    }
    return value.trim()
}
/**
 * 加载专家介绍页真实接口,失败时回退到静态内容。
 */
async function loadExpertDetail(expertId?: string) {
    if (!expertId) {
        expertDetail.value = createFallbackExpertDetail()
        expertIntroNodes.value = buildRichTextNodes(expertDetail.value.intro, '暂无专家介绍')
        return
    }
    try {
        const response = await getLiveExpertDetail(expertId)
        if (response?.code !== 0 || !response.data) {
            expertDetail.value = createFallbackExpertDetail()
            expertIntroNodes.value = buildRichTextNodes(expertDetail.value.intro, '暂无专家介绍')
        if (this.pageType === 'privacy') {
            this.loadAgreementContent(this.pageType)
            return
        }
        expertDetail.value = {
            ...createFallbackExpertDetail(),
            ...response.data,
        if (this.pageType === 'expert') {
            uni.setNavigationBarTitle({
                title: '专家老师介绍'
            })
            this.loadExpertDetail(typeof options?.id === 'string' ? options.id : '')
        }
        expertIntroNodes.value = buildRichTextNodes(expertDetail.value.intro, '暂无专家介绍')
    } catch {
        expertDetail.value = createFallbackExpertDetail()
        expertIntroNodes.value = buildRichTextNodes(expertDetail.value.intro, '暂无专家介绍')
    },
    methods: {
        /**
         * 创建专家介绍页的兜底静态数据,避免旧入口无参时页面空白。
         */
        createFallbackExpertDetail() {
            return {
                id: null,
                name: '张雨泽',
                avatar: '/static/image/home/avatr.png',
                specialty: '中医文化传播',
                intro: '专家老师详细介绍内容。张雨泽老师在相关领域拥有多年的丰富经验,致力于传播中医文化,培养优秀的专业人才。',
            }
        },
        /**
         * 加载协议详情页真实接口,未配置时返回空富文本兜底。
         */
        async loadAgreementContent(type) {
            uni.setNavigationBarTitle({
                title: this.resolveAgreementTitle(type),
            })
            this.htmlContent = this.buildRichTextNodes('', '暂无协议内容')
            try {
                const response = await getAgreementContentDetail(type)
                if (!response || response.code !== 0 || !response.data) {
                    return
                }
                const agreementName = this.normalizeText(response.data.agreementName) || this.resolveAgreementTitle(type)
                uni.setNavigationBarTitle({
                    title: agreementName,
                })
                const contentHtml = this.normalizeText(response.data.contentHtml)
                if (!contentHtml) {
                    return
                }
                this.htmlContent = this.buildRichTextNodes(contentHtml, '暂无协议内容')
            } catch (error) {
                // 请求层会统一处理异常提示,这里保留兜底空内容即可。
            }
        },
        /**
         * 将接口返回的富文本正文转换为 `rich-text` 可直接渲染的节点字符串。
         */
        buildRichTextNodes(content, fallbackText) {
            if (typeof content === 'string' && content.trim()) {
                return content
                    .replace(/<img/gi, '<img style="max-width:100%;height:auto;margin:0 auto;display:block"')
                    .replace(/<p/gi, '<p style="margin:10px 0;line-height:1.8;word-break:break-all"')
                    .replace(/<div/gi, '<div style="word-break:break-all"')
            }
            if (typeof fallbackText === 'string' && fallbackText.trim()) {
                return `<p>${fallbackText.trim()}</p>`
            }
            return '<p></p>'
        },
        /**
         * 根据页面类型返回协议页默认标题。
         */
        resolveAgreementTitle(type) {
            if (type === 'privacy') {
                return DEFAULT_PRIVACY_TITLE
            }
            return DEFAULT_USER_AGREEMENT_TITLE
        },
        /**
         * 将可选文本归一化为空字符串。
         */
        normalizeText(value) {
            if (typeof value !== 'string') {
                return ''
            }
            return value.trim()
        },
        /**
         * 加载专家介绍页真实接口,失败时回退到静态内容。
         */
        async loadExpertDetail(expertId) {
            if (!expertId) {
                this.expertDetail = this.createFallbackExpertDetail()
                this.expertIntroNodes = this.buildRichTextNodes(this.expertDetail.intro, '暂无专家介绍')
                return
            }
            try {
                const response = await getLiveExpertDetail(expertId)
                if (response?.code !== 0 || !response.data) {
                    this.expertDetail = this.createFallbackExpertDetail()
                    this.expertIntroNodes = this.buildRichTextNodes(this.expertDetail.intro, '暂无专家介绍')
                    return
                }
                this.expertDetail = {
                    ...this.createFallbackExpertDetail(),
                    ...response.data,
                }
                this.expertIntroNodes = this.buildRichTextNodes(this.expertDetail.intro, '暂无专家介绍')
            } catch {
                this.expertDetail = this.createFallbackExpertDetail()
                this.expertIntroNodes = this.buildRichTextNodes(this.expertDetail.intro, '暂无专家介绍')
            }
        }
    }
}
/**
 * 页面加载时根据 type 参数分发到协议页或专家介绍页。
 */
onLoad((options) => {
    pageType.value = typeof options?.type === 'string' ? options.type : ''
    if (pageType.value === 'user') {
        loadAgreementContent(pageType.value)
        return
    }
    if (pageType.value === 'privacy') {
        loadAgreementContent(pageType.value)
        return
    }
    if (pageType.value === 'expert') {
        uni.setNavigationBarTitle({
            title: '专家老师介绍'
        })
        loadExpertDetail(typeof options?.id === 'string' ? options.id : '')
    }
})
</script>
<style lang="scss" scoped>
@@ -165,8 +170,15 @@
    box-sizing: border-box;
}
.protocol-content {
    padding: 32rpx;
}
.rich-content {
    width: 100%;
    font-size: 28rpx;
    line-height: 1.8;
    color: #333;
}
.expert-page {
traditionalApp/pages/protocol/service.js
@@ -1,8 +1,8 @@
import request from '@/utils/request.js'
import { publicGet } from '@/utils/public-request.js'
/**
 * 获取协议内容详情。
 */
export const getAgreementContentDetail = (type) => {
    return request.get(`/api/app/agreement-contents/${type}`, {}, false, false)
    return publicGet(`/api/app/agreement-contents/${type}`)
}
traditionalApp/pages/watchLive/watchLive.vue
@@ -15,12 +15,12 @@
            <view v-if="isVideoPlayable" class="video-player-wrapper" :style="videoSectionStyle" @tap="handleVideoWrapperTap">
                <video id="live-watch-player" class="video-player" :style="videoSectionStyle" :src="liveDetail.videoUrl"
                    :poster="videoPoster" :initial-time="liveInitialTime" :controls="false" :autoplay="false"
                    :show-center-play-btn="false" :show-fullscreen-btn="false" :show-play-btn="false"
                    :show-center-play-btn="showNativeCenterPlayButton" :show-fullscreen-btn="false" :show-play-btn="false"
                    :show-progress="false" :enable-progress-gesture="false" :muted="isReadonlyDisplayMode" object-fit="contain"
                    play-btn-position="center" @loadedmetadata="handleVideoLoadedMetadata" @canplay="handleVideoCanPlay"
                    @timeupdate="handleVideoTimeUpdate" @play="handleVideoPlay" @pause="handleVideoPause"
                    @ended="handleVideoEnded" @error="handleVideoError" @fullscreenchange="handleFullscreenChange"></video>
                <view class="video-touch-layer"></view>
                <cover-view v-if="showVideoGestureBlocker" class="video-touch-layer"></cover-view>
                <view v-if="showAutoPlayLoader" class="video-loader-mask">
                    <view class="video-loader-ring" :style="videoLoaderRingStyle">
                        <view class="video-loader-ring__inner">
@@ -29,17 +29,23 @@
                        </view>
                    </view>
                </view>
                <view v-if="showPlaybackRetry" class="video-retry-mask" @tap.stop="retryPlayback">
                    <text class="video-retry-text">点击开始播放</text>
                </view>
                <view v-if="showCustomFullscreenButton" class="video-fullscreen-btn" @tap.stop="toggleFullscreen">
                    <view class="video-fullscreen-btn__icon">
                        <view class="video-fullscreen-btn__corner video-fullscreen-btn__corner--lt"></view>
                        <view class="video-fullscreen-btn__corner video-fullscreen-btn__corner--rt"></view>
                        <view class="video-fullscreen-btn__corner video-fullscreen-btn__corner--lb"></view>
                        <view class="video-fullscreen-btn__corner video-fullscreen-btn__corner--rb"></view>
                    </view>
                </view>
                <cover-view v-if="showManualPlayLoader" class="video-cover-loader-mask">
                    <cover-view class="video-cover-loader-ring" :style="videoLoaderRingStyle">
                        <cover-view class="video-cover-loader-ring__inner">
                            <cover-view class="video-cover-loader-percent">{{ autoPlayLoaderPercentText }}</cover-view>
                            <cover-view class="video-cover-loader-label">加载中</cover-view>
                        </cover-view>
                    </cover-view>
                </cover-view>
                <cover-view v-if="showCustomPlayButton" class="video-cover-play-button" @tap="startManualPlayback">
                    ▶
                </cover-view>
                <cover-view v-if="showEnterFullscreenButton" class="video-cover-fullscreen-btn" @tap="toggleFullscreen">
                    全屏
                </cover-view>
                <cover-view v-if="showExitFullscreenButton" class="video-cover-fullscreen-btn video-cover-fullscreen-btn--exit" @tap="toggleFullscreen">
                    退出
                </cover-view>
            </view>
            <image v-else class="video-bg" :src="videoPoster" mode="aspectFill"></image>
            <view v-if="!isVideoPlayable" class="video-placeholder">
@@ -173,7 +179,7 @@
</template>
<script setup lang="ts">
    import { computed, getCurrentInstance, nextTick, ref } from 'vue';
    import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue';
    import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app';
    import { getLiveCourseDetail, getLiveQuizStatus, submitLiveQuiz, uploadLiveWatchRecord } from './service';
    import { getHelpCenter } from '../helpCenter/service';
@@ -311,6 +317,8 @@
    const playbackFailed = ref(false)
    const pendingPlaybackSync = ref(false)
    const playbackSyncAttempts = ref(0)
    const isManualPlayLoaderVisible = ref(false)
    const shouldResyncOnNextPlay = ref(false)
    const lastMediaCurrentTime = ref(0)
    const currentWatchStartedAt = ref(0)
    const autoPlayLoaderPercent = ref(0)
@@ -322,6 +330,7 @@
    let videoContext : ReturnType<typeof uni.createVideoContext> | null = null
    let playbackStartupTimer : ReturnType<typeof setTimeout> | null = null
    let playbackSyncTimer : ReturnType<typeof setTimeout> | null = null
    let manualPlayLoaderTimer : ReturnType<typeof setTimeout> | null = null
    let quizAvailabilityTimer : ReturnType<typeof setInterval> | null = null
    let autoPlayLoaderTimer : ReturnType<typeof setInterval> | null = null
    let autoPlayLoaderFinishTimer : ReturnType<typeof setTimeout> | null = null
@@ -517,23 +526,28 @@
    const pageTitle = computed(() => liveDetail.value.title || '直播观看')
    const isReadonlyDisplayMode = isH5
    const isLoaderGatedPlayback = computed(() => isReadonlyDisplayMode || isAppPlus)
    const isLoaderGatedPlayback = computed(() => isReadonlyDisplayMode)
    const showNativeCenterPlayButton = computed(() => false)
    const videoPoster = computed(() => liveDetail.value.displayImage || liveDetail.value.coverImage || defaultVideoPoster)
    const videoSectionStyle = computed(() => ({
        height: showAnswer.value ? '500rpx' : '1234rpx',
    }))
    const showPlaybackRetry = computed(() => {
        if (isReadonlyDisplayMode) {
            return false
        }
        if (!isVideoPlayable.value || !isVideoReady.value) {
            return false
        }
        if (playbackFailed.value) {
            return true
        }
        // H5 浏览器禁止未交互自动播放时,直接展示点击播放提示,避免首屏触发 NotAllowedError。
        return isH5 && !isPlaybackStarted.value
    const showCustomPlayButton = computed(() => {
        return !isReadonlyDisplayMode
            && isVideoPlayable.value
            && !isManualPlayLoaderVisible.value
            && (!isPlaybackStarted.value || isVideoPaused.value || playbackFailed.value)
    })
    const showManualPlayLoader = computed(() => {
        return !isReadonlyDisplayMode
            && isVideoPlayable.value
            && isManualPlayLoaderVisible.value
    })
    const showVideoGestureBlocker = computed(() => {
        return isVideoPlayable.value
            && !showAutoPlayLoader.value
            && !showManualPlayLoader.value
            && !showCustomPlayButton.value
    })
    const activeSessionId = computed(() => {
        if (liveDetail.value.actionSessionId != null && liveDetail.value.actionSessionId !== '') {
@@ -646,7 +660,7 @@
            return '直播已结束,请完成答题'
        }
        if (!liveDetail.value.videoUrl) {
            return '直播视频准备中'
            return liveDetail.value.displayStatus === 'ENDED' ? '直播已结束,请完成答题' : '直播视频准备中'
        }
        return '当前直播暂不可观看'
    })
@@ -661,10 +675,22 @@
            background: `conic-gradient(#ffffff ${Math.min(autoPlayLoaderPercent.value, 100) * 3.6}deg, rgba(255, 255, 255, 0.2) 0deg)`,
        }
    })
    const showCustomFullscreenButton = computed(() => {
        return isVideoPlayable.value
    const showEnterFullscreenButton = computed(() => {
        return isAppPlus
            && isVideoPlayable.value
            && !showAutoPlayLoader.value
            && !showManualPlayLoader.value
            && !showCustomPlayButton.value
            && !playbackFailed.value
            && !isReadonlyDisplayMode
            && !isVideoFullscreen.value
    })
    const showExitFullscreenButton = computed(() => {
        return isAppPlus
            && isVideoPlayable.value
            && !playbackFailed.value
            && !isReadonlyDisplayMode
            && isVideoFullscreen.value
    })
    /**
@@ -676,12 +702,31 @@
        sessionId.value = resolvedParams.sessionId
    })
    onMounted(() => {
        setTimeout(() => {
            if (courseId.value) {
                loadLiveDetail()
                return
            }
            const resolvedParams = resolveWatchRouteParams(getCurrentPageOptions())
            courseId.value = resolvedParams.courseId
            sessionId.value = resolvedParams.sessionId
            loadLiveDetail()
        }, 0)
    })
    function getCurrentPageOptions() {
        const pages = getCurrentPages()
        const currentPage = pages[pages.length - 1] as { options ?: Record<string, unknown>; $page ?: { options ?: Record<string, unknown> } } | undefined
        return currentPage?.options || currentPage?.$page?.options || {}
    }
    /**
     * 统一解析观看页参数,兼容 App 路由和 H5 分享地址。
     */
    function resolveWatchRouteParams(options ?: Record<string, unknown>) {
        const routeCourseId = typeof options?.courseId === 'string' ? options.courseId.trim() : ''
        const routeSessionId = typeof options?.sessionId === 'string' ? options.sessionId.trim() : ''
        const routeCourseId = normalizeRouteParam(options?.courseId)
        const routeSessionId = normalizeRouteParam(options?.sessionId)
        if (routeCourseId || routeSessionId) {
            return {
                courseId: routeCourseId,
@@ -715,11 +760,23 @@
        }
    }
    function normalizeRouteParam(value : unknown) {
        if (value === undefined || value === null) {
            return ''
        }
        return String(value).trim()
    }
    /**
     * 每次展示观看页时刷新详情,保证取到最新可观看视频与答题状态。
     */
    onShow(() => {
        loadLiveDetail()
        const shouldResumeAfterRefresh = !isReadonlyDisplayMode
            && isVideoPlayable.value
            && (isVideoPaused.value || playbackFailed.value)
        loadLiveDetail({
            resumeIfPaused: shouldResumeAfterRefresh,
        })
    })
    /**
@@ -728,7 +785,9 @@
    onHide(() => {
        clearPlaybackStartupTimer()
        clearPlaybackSyncTimer()
        clearManualPlayLoaderTimer()
        clearAutoPlayLoaderTimers()
        isManualPlayLoaderVisible.value = false
        clearQuizAvailabilityTimer()
        submitCurrentWatchSegment()
    })
@@ -739,7 +798,9 @@
    onUnload(() => {
        clearPlaybackStartupTimer()
        clearPlaybackSyncTimer()
        clearManualPlayLoaderTimer()
        clearAutoPlayLoaderTimers()
        isManualPlayLoaderVisible.value = false
        clearQuizAvailabilityTimer()
        submitCurrentWatchSegment()
    })
@@ -747,11 +808,12 @@
    /**
     * 拉取直播详情并回填观看页视频信息。
     */
    async function loadLiveDetail() {
    async function loadLiveDetail(options : { resumeIfPaused ?: boolean } = {}) {
        if (!courseId.value) {
            loadFailed.value = true
            return
        }
        const shouldResumeIfPaused = !!options.resumeIfPaused
        resetVideoPlaybackState()
        resetQuizState()
        loading.value = true
@@ -789,6 +851,10 @@
            await nextTick()
            initVideoContext()
            startAutoPlayLoaderIfNeeded()
            if (shouldResumeIfPaused && isVideoPlayable.value) {
                shouldResyncOnNextPlay.value = true
                retryPlayback()
            }
            if (hasLoginToken() && activeSessionId.value) {
                await loadQuizStatus()
            }
@@ -850,6 +916,7 @@
    function resetVideoPlaybackState() {
        clearPlaybackStartupTimer()
        clearPlaybackSyncTimer()
        clearManualPlayLoaderTimer()
        clearAutoPlayLoaderTimers()
        currentWatchStartedAt.value = 0
        isVideoReady.value = false
@@ -861,6 +928,8 @@
        playbackFailed.value = false
        pendingPlaybackSync.value = false
        playbackSyncAttempts.value = 0
        isManualPlayLoaderVisible.value = false
        shouldResyncOnNextPlay.value = false
        lastMediaCurrentTime.value = 0
        autoPlayLoaderPercent.value = 0
        isAutoPlayLoaderVisible.value = false
@@ -1021,6 +1090,48 @@
    }
    /**
     * 清理手动播放加载遮罩的进度定时器。
     */
    function clearManualPlayLoaderTimer() {
        if (manualPlayLoaderTimer) {
            clearTimeout(manualPlayLoaderTimer)
            manualPlayLoaderTimer = null
        }
    }
    /**
     * 推进手动播放加载环到 99%,真实播放成功后再收尾到 100%。
     */
    function updateManualPlayLoaderProgress(startTimestamp : number) {
        const elapsed = Date.now() - startTimestamp
        autoPlayLoaderPercent.value = Math.min(
            AUTO_PLAY_LOADER_TARGET,
            Math.floor((elapsed / AUTO_PLAY_LOADER_DURATION) * AUTO_PLAY_LOADER_TARGET),
        )
        if (!isManualPlayLoaderVisible.value) {
            manualPlayLoaderTimer = null
            return
        }
        manualPlayLoaderTimer = setTimeout(() => {
            updateManualPlayLoaderProgress(startTimestamp)
        }, 60)
    }
    /**
     * 播放真正成功后,把手动加载环补到 100% 并立即关闭遮罩。
     */
    function finishManualPlayLoader() {
        if (!isManualPlayLoaderVisible.value) {
            return
        }
        clearManualPlayLoaderTimer()
        autoPlayLoaderPercent.value = 100
        setTimeout(() => {
            isManualPlayLoaderVisible.value = false
        }, 120)
    }
    /**
     * 清理播放进度同步重试定时器,避免页面隐藏后继续 seek。
     */
    function clearPlaybackSyncTimer() {
@@ -1045,7 +1156,7 @@
    }
    /**
     * 进入伪直播后先展示加载环,再进入横屏全屏并按当前直播进度播放。
     * 进入伪直播后先展示加载环,加载完成后交给用户手动播放。
     */
    function startAutoPlayLoaderIfNeeded() {
        if (!isLoaderGatedPlayback.value || !isVideoPlayable.value || hasStartedReadonlyAutoPlay.value) {
@@ -1066,9 +1177,7 @@
            if (elapsed >= AUTO_PLAY_LOADER_DURATION) {
                clearAutoPlayLoaderTimers()
                autoPlayLoaderPercent.value = AUTO_PLAY_LOADER_TARGET
                hasQueuedReadonlyAutoPlay.value = true
                requestLandscapeFullscreenIfNeeded()
                tryStartPlayback()
                isAutoPlayLoaderVisible.value = false
            }
        }, 60)
    }
@@ -1095,6 +1204,8 @@
                isStartingPlayback.value = false
                playbackFailed.value = true
                isVideoPaused.value = true
                clearManualPlayLoaderTimer()
                isManualPlayLoaderVisible.value = false
            }
        }, 1200)
    }
@@ -1232,17 +1343,19 @@
            }
            return
        }
        syncPlaybackWhenReady(true)
        isVideoPaused.value = true
        if (pendingPlaybackSync.value || hasQueuedReadonlyAutoPlay.value) {
            syncPlaybackWhenReady(hasQueuedReadonlyAutoPlay.value)
        }
    }
    /**
     * 视频区域点击仅保留给 App 端暂停/恢复,分享页不提供手动播放按钮。
     */
    function handleVideoWrapperTap() {
        if (isReadonlyDisplayMode) {
        if (!isReadonlyDisplayMode) {
            return
        }
        togglePlayback()
    }
    /**
@@ -1262,7 +1375,39 @@
    /**
     * 手动重试当前视频播放,复用统一的自动播放拉起逻辑。
     */
    function startManualPlayback() {
        if (!isVideoPlayable.value || isManualPlayLoaderVisible.value) {
            return
        }
        clearManualPlayLoaderTimer()
        autoPlayLoaderPercent.value = 0
        playbackFailed.value = false
        isVideoPaused.value = false
        isManualPlayLoaderVisible.value = true
        updateManualPlayLoaderProgress(Date.now())
        retryPlayback()
    }
    /**
     * 按当前伪直播进度拉起播放。
     */
    function retryPlayback() {
        if (!videoContext) {
            initVideoContext()
        }
        hasQueuedReadonlyAutoPlay.value = true
        isVideoPaused.value = false
        playbackFailed.value = false
        if (!supportsSimulatedPlayback.value || !isVideoReady.value || mediaDurationSeconds.value <= 0) {
            pendingPlaybackSync.value = true
            triggerVideoPlay()
            return
        }
        if (shouldResyncOnNextPlay.value) {
            shouldResyncOnNextPlay.value = false
            syncPlaybackWhenReady(true)
            return
        }
        tryStartPlayback()
    }
@@ -1336,6 +1481,17 @@
        isPlaybackStarted.value = true
        playbackFailed.value = false
        isVideoPaused.value = false
        finishManualPlayLoader()
        if (shouldResyncOnNextPlay.value && supportsSimulatedPlayback.value && isVideoReady.value && mediaDurationSeconds.value > 0) {
            shouldResyncOnNextPlay.value = false
            seekToExpectedPlayback(false, false)
        }
        if (!hasQueuedReadonlyAutoPlay.value) {
            hasQueuedReadonlyAutoPlay.value = true
            if (supportsSimulatedPlayback.value && isVideoReady.value && mediaDurationSeconds.value > 0) {
                seekToExpectedPlayback(false, false)
            }
        }
        if (isLoaderGatedPlayback.value) {
            clearAutoPlayLoaderTimers()
            autoPlayLoaderPercent.value = 100
@@ -1373,6 +1529,14 @@
        if (isSyncingPlayback.value) {
            return
        }
        if (isVideoFullscreen.value) {
            isVideoPaused.value = false
            if (!isStartingPlayback.value) {
                shouldResyncOnNextPlay.value = true
                retryPlayback()
            }
            return
        }
        if (isStartingPlayback.value && !currentWatchStartedAt.value) {
            clearPlaybackStartupTimer()
            isStartingPlayback.value = false
@@ -1408,11 +1572,13 @@
     */
    function handleVideoError(event : VideoErrorEvent) {
        clearPlaybackStartupTimer()
        clearManualPlayLoaderTimer()
        clearAutoPlayLoaderTimers()
        isStartingPlayback.value = false
        isPlaybackStarted.value = false
        playbackFailed.value = true
        isVideoPaused.value = true
        isManualPlayLoaderVisible.value = false
        isAutoPlayLoaderVisible.value = false
        if (event?.detail?.errMsg) {
            uni.showToast({
@@ -1670,16 +1836,19 @@
    }
    /**
     * 记录全屏切换状态,驱动自绘按钮文案与退出逻辑。
     * 记录原生全屏状态,驱动自绘全屏按钮。
     */
    function handleFullscreenChange(event : VideoFullscreenChangeEvent) {
        isVideoFullscreen.value = !!(event?.detail?.fullScreen || event?.detail?.fullscreen)
    }
    /**
     * 使用自绘按钮触发全屏,避免依赖原生播放控制条。
     * 使用原生 video 全屏,避免伪全屏放大页面;原生 controls 仍保持关闭。
     */
    function toggleFullscreen() {
        if (!videoContext) {
            initVideoContext()
        }
        if (!videoContext) {
            return
        }
@@ -1689,6 +1858,9 @@
        }
        videoContext.requestFullScreen({
            direction: 90,
            success: () => {
                isVideoFullscreen.value = true
            },
        })
    }
</script>
@@ -1822,87 +1994,105 @@
            line-height: 1.2;
        }
        .video-retry-mask {
        .video-cover-play-button {
            position: absolute;
            inset: 0;
            z-index: 3;
            display: flex;
            align-items: center;
            justify-content: center;
            background: rgba(0, 0, 0, 0.38);
        }
        .video-retry-text {
            padding: 20rpx 36rpx;
            border-radius: 999rpx;
            background: rgba(255, 255, 255, 0.18);
            left: 315rpx;
            top: 50%;
            z-index: 101;
            width: 120rpx;
            height: 120rpx;
            line-height: 120rpx;
            margin-top: -60rpx;
            border-radius: 60rpx;
            background-color: rgba(0, 0, 0, 0.68);
            color: #ffffff;
            font-size: 28rpx;
            line-height: 40rpx;
            font-size: 52rpx;
            font-weight: 600;
            text-align: center;
        }
        .video-fullscreen-btn {
        .video-cover-loader-mask {
            position: absolute;
            right: 24rpx;
            bottom: 24rpx;
            z-index: 4;
            width: 76rpx;
            height: 76rpx;
            border-radius: 20rpx;
            background: rgba(0, 0, 0, 0.52);
            display: flex;
            align-items: center;
            justify-content: center;
            backdrop-filter: blur(8rpx);
            left: 0;
            top: 0;
            z-index: 102;
            width: 750rpx;
            height: 100%;
            background-color: rgba(0, 0, 0, 0.28);
        }
        .video-fullscreen-btn__icon {
            position: relative;
            width: 32rpx;
        .video-cover-loader-ring {
            position: absolute;
            left: 285rpx;
            top: 50%;
            width: 180rpx;
            height: 180rpx;
            margin-top: -90rpx;
            border-radius: 90rpx;
            padding: 10rpx;
            box-sizing: border-box;
            background-color: rgba(255, 255, 255, 0.2);
        }
        .video-cover-loader-ring__inner {
            position: absolute;
            left: 10rpx;
            top: 10rpx;
            width: 160rpx;
            height: 160rpx;
            border-radius: 80rpx;
            background-color: rgba(12, 12, 12, 0.78);
        }
        .video-cover-loader-percent {
            position: absolute;
            left: 0;
            top: 42rpx;
            width: 160rpx;
            height: 48rpx;
            line-height: 48rpx;
            color: #ffffff;
            font-size: 40rpx;
            font-weight: 600;
            text-align: center;
        }
        .video-cover-loader-label {
            position: absolute;
            left: 0;
            top: 96rpx;
            width: 160rpx;
            height: 32rpx;
            line-height: 32rpx;
            color: rgba(255, 255, 255, 0.82);
            font-size: 22rpx;
            text-align: center;
        }
        .video-fullscreen-btn__corner {
        .video-cover-fullscreen-btn {
            position: absolute;
            width: 12rpx;
            height: 12rpx;
            border-color: #ffffff;
            right: 28rpx;
            bottom: 28rpx;
            z-index: 120;
            width: 88rpx;
            height: 88rpx;
            line-height: 88rpx;
            border-radius: 44rpx;
            border-width: 2rpx;
            border-style: solid;
            border-color: rgba(255, 255, 255, 0.9);
            background-color: rgba(0, 0, 0, 0.86);
            color: #ffffff;
            font-size: 42rpx;
            font-weight: 600;
            text-align: center;
        }
        .video-fullscreen-btn__corner--lt {
            top: 0;
            left: 0;
            border-top-width: 3rpx;
            border-left-width: 3rpx;
            border-top-style: solid;
            border-left-style: solid;
        }
        .video-fullscreen-btn__corner--rt {
            top: 0;
            right: 0;
            border-top-width: 3rpx;
            border-right-width: 3rpx;
            border-top-style: solid;
            border-right-style: solid;
        }
        .video-fullscreen-btn__corner--lb {
            left: 0;
            bottom: 0;
            border-bottom-width: 3rpx;
            border-left-width: 3rpx;
            border-bottom-style: solid;
            border-left-style: solid;
        }
        .video-fullscreen-btn__corner--rb {
            right: 0;
            bottom: 0;
            border-right-width: 3rpx;
            border-bottom-width: 3rpx;
            border-right-style: solid;
            border-bottom-style: solid;
        .video-cover-fullscreen-btn--exit {
            position: fixed;
            right: 32rpx;
            top: 32rpx;
            z-index: 9999;
        }
        .video-placeholder {
traditionalApp/utils/public-request.js
New file
@@ -0,0 +1,26 @@
import config from '@/config/index.js'
/**
 * 游客可访问接口专用请求,避免被本地登录态刷新、旧 token、请求队列影响。
 */
export function publicGet(url, data = {}) {
    const requestUrl = `${config.BASE_URL}${url}`
    return new Promise((resolve, reject) => {
        uni.request({
            url: requestUrl,
            method: 'GET',
            data,
            timeout: 10000,
            header: {
                'content-type': 'application/json',
                lang: uni.getStorageSync('locale') === 'zh-Hans' ? 'zh_CN' : 'zh-tw',
            },
            success: (res) => {
                resolve(res.data || {})
            },
            fail: (error) => {
                reject(error)
            },
        })
    })
}
traditionalApp/utils/push.js
@@ -93,7 +93,7 @@
        return null
    }
    if (typeof jpushModule.setLoggerEnable === 'function') {
        jpushModule.setLoggerEnable(true)
        jpushModule.setLoggerEnable(false)
    }
    if (typeof jpushModule.initJPushService === 'function') {
        jpushModule.initJPushService()
@@ -101,7 +101,6 @@
    if (typeof jpushModule.addConnectEventListener === 'function') {
        jpushModule.addConnectEventListener((result) => {
            const connectEnable = !!(result && result.connectEnable)
            console.log('极光连接状态', result)
            if (!connectEnable) {
                return
            }
@@ -110,7 +109,6 @@
    }
    if (typeof jpushModule.addNotificationListener === 'function') {
        jpushModule.addNotificationListener((result) => {
            console.log('48948949841weqwe', result);
            handleJPushNotification(result)
        })
    }
traditionalApp/utils/request.js
@@ -18,9 +18,14 @@
const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000
const SUCCESS_CODES = [0, 200]
const UNAUTHORIZED_CODES = [401, 504, 505, 510]
const SESSION_INVALID_MESSAGES = ['用户不存在', '用户已禁用']
const PENDING_BIND_ALLOWED_URLS = [
    '/api/app/settings/phone/code',
    '/api/app/settings/phone/bind',
]
const PUBLIC_APP_URL_PREFIXES = [
    '/api/app/home/',
    '/api/v1/app/home/',
]
const waitingList = []
@@ -49,6 +54,17 @@
}
/**
 * 后端部分已登录接口会以业务错误返回“用户不存在/已禁用”,这里统一视为登录态失效。
 */
function isSessionInvalidPayload(payload) {
    if (!getStoredToken()) {
        return false
    }
    const message = resolveMessage(payload).trim()
    return SESSION_INVALID_MESSAGES.includes(message)
}
/**
 * 读取当前语言请求头值。
 */
function resolveLangHeader() {
@@ -59,21 +75,35 @@
 * 构造当前请求头。
 */
function buildHeaders(type, url) {
    return {
    const authorization = resolveAuthorizationHeader(url)
    const headers = {
        'content-type': type ? 'application/x-www-form-urlencoded;charset=UTF-8' : 'application/json',
        Authorization: resolveAuthorizationHeader(url),
        lang: resolveLangHeader(),
    }
    if (authorization) {
        headers.Authorization = authorization
    }
    return headers
}
/**
 * 解析请求鉴权头,待绑定手机号临时 token 仅允许访问绑定手机号接口。
 */
function resolveAuthorizationHeader(url) {
    if (isPublicAppUrl(url)) {
        return ''
    }
    if (!hasPendingPhoneBindSession()) {
        return getStoredToken()
    }
    return isPendingBindAllowedUrl(url) ? getStoredToken() : ''
}
/**
 * 首页公共接口允许游客访问,不能被本地旧登录态或 token 刷新失败影响。
 */
function isPublicAppUrl(url) {
    return PUBLIC_APP_URL_PREFIXES.some((prefix) => url.startsWith(prefix))
}
/**
@@ -122,6 +152,11 @@
                    }
                    if (payload.code === 502 || payload.code === 501) {
                        showErrorToast(payload)
                        resolve(payload)
                        return
                    }
                    if (isSessionInvalidPayload(payload)) {
                        handleUnauthorized(payload)
                        resolve(payload)
                        return
                    }
@@ -252,6 +287,9 @@
 * 判断当前请求是否需要在发送前尝试刷新 token。
 */
function shouldAttemptTokenRefresh(url) {
    if (isPublicAppUrl(url)) {
        return false
    }
    const token = getStoredToken()
    if (!token || !hasBoundPhone()) {
        return false
@@ -287,7 +325,7 @@
                    resolve(payload)
                    return
                }
                if (UNAUTHORIZED_CODES.includes(payload.code)) {
                if (UNAUTHORIZED_CODES.includes(payload.code) || isSessionInvalidPayload(payload)) {
                    handleUnauthorized(payload)
                }
                reject(payload)