From ee54919d10dea640af38b8b3873710bca356d82a Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期五, 03 七月 2026 15:16:43 +0800
Subject: [PATCH] 部分优化
---
traditionalApp/pages/courseDetail/service.js | 3
traditionalApp/pages/home/home.vue | 158 ++++---
traditionalApp/pages/mine/mine.vue | 76 ++-
traditionalApp/pages/liveDetail/liveDetail.vue | 38 +
traditionalApp/pages/watchLive/watchLive.vue | 412 ++++++++++++++-----
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java | 11
traditionalApp/pages/courseDetail/courseDetail.vue | 65 ++
traditionalApp/pages/articleDetail/service.js | 4
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java | 1
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java | 3
tcm-training-admin/src/pages/live/health/manage.tsx | 6
traditionalApp/pages/articleDetail/articleDetail.vue | 24 +
traditionalApp/pages/home/service.js | 14
tcm-training-admin/src/pages/live/health/detail.tsx | 2
traditionalApp/pages/protocol/protocol.vue | 266 ++++++------
traditionalApp/pages/liveDetail/service.js | 5
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java | 8
tcm-training-admin/src/pages/live/health/index.tsx | 7
traditionalApp/utils/request.js | 44 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java | 1
tcm-training-admin-backend/src/main/resources/application.yml | 2
traditionalApp/pages/login/login.vue | 7
traditionalApp/utils/push.js | 4
.tmp_watchlive_frames/video.html | 1
traditionalApp/pages/protocol/service.js | 4
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAuthProperties.java | 31 +
traditionalApp/utils/public-request.js | 26 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java | 2
traditionalApp/manifest.json | 6
29 files changed, 847 insertions(+), 384 deletions(-)
diff --git a/.tmp_watchlive_frames/video.html b/.tmp_watchlive_frames/video.html
new file mode 100644
index 0000000..db4e512
--- /dev/null
+++ b/.tmp_watchlive_frames/video.html
@@ -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>
\ No newline at end of file
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAuthProperties.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAuthProperties.java
new file mode 100644
index 0000000..4f51122
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAuthProperties.java
@@ -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;
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java
index e684517..3ba9c8b 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java
+++ b/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());
}
/**
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java
index 44adc37..735881d 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java
+++ b/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));
}
/**
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java
index b454b7f..33d83dd 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java
+++ b/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;
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
index 743ff41..34eaa0e 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
+++ b/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;
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java
index c039624..134f5e7 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListItemVO.java
+++ b/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;
}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java
index 4c583c0..51035c7 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthCourseListRow.java
+++ b/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;
}
diff --git a/tcm-training-admin-backend/src/main/resources/application.yml b/tcm-training-admin-backend/src/main/resources/application.yml
index 1fbb6f2..1cf905c 100644
--- a/tcm-training-admin-backend/src/main/resources/application.yml
+++ b/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
diff --git a/tcm-training-admin/src/pages/live/health/detail.tsx b/tcm-training-admin/src/pages/live/health/detail.tsx
index 9c7049f..574b749 100644
--- a/tcm-training-admin/src/pages/live/health/detail.tsx
+++ b/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>
diff --git a/tcm-training-admin/src/pages/live/health/index.tsx b/tcm-training-admin/src/pages/live/health/index.tsx
index 173cb56..5f47466 100644
--- a/tcm-training-admin/src/pages/live/health/index.tsx
+++ b/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,
diff --git a/tcm-training-admin/src/pages/live/health/manage.tsx b/tcm-training-admin/src/pages/live/health/manage.tsx
index 496c7c8..a6aeb9f 100644
--- a/tcm-training-admin/src/pages/live/health/manage.tsx
+++ b/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>
diff --git a/traditionalApp/manifest.json b/traditionalApp/manifest.json
index b3efb10..db08613 100644
--- a/traditionalApp/manifest.json
+++ b/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" : {
diff --git a/traditionalApp/pages/articleDetail/articleDetail.vue b/traditionalApp/pages/articleDetail/articleDetail.vue
index 8404f2b..8e9860e 100644
--- a/traditionalApp/pages/articleDetail/articleDetail.vue
+++ b/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>
\ No newline at end of file
+</style>
diff --git a/traditionalApp/pages/articleDetail/service.js b/traditionalApp/pages/articleDetail/service.js
index 83f8d91..ad46ff2 100644
--- a/traditionalApp/pages/articleDetail/service.js
+++ b/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}`)
}
diff --git a/traditionalApp/pages/courseDetail/courseDetail.vue b/traditionalApp/pages/courseDetail/courseDetail.vue
index 892c44c..7a2ec50 100644
--- a/traditionalApp/pages/courseDetail/courseDetail.vue
+++ b/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 兜底。
*/
diff --git a/traditionalApp/pages/courseDetail/service.js b/traditionalApp/pages/courseDetail/service.js
index 598318e..45f13df 100644
--- a/traditionalApp/pages/courseDetail/service.js
+++ b/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}`)
}
/**
diff --git a/traditionalApp/pages/home/home.vue b/traditionalApp/pages/home/home.vue
index 8a95ea9..bd7df53 100644
--- a/traditionalApp/pages/home/home.vue
+++ b/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 {
diff --git a/traditionalApp/pages/home/service.js b/traditionalApp/pages/home/service.js
index 482e11d..bd4d223 100644
--- a/traditionalApp/pages/home/service.js
+++ b/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')
}
/**
diff --git a/traditionalApp/pages/liveDetail/liveDetail.vue b/traditionalApp/pages/liveDetail/liveDetail.vue
index 6970a62..4bb2ce6 100644
--- a/traditionalApp/pages/liveDetail/liveDetail.vue
+++ b/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 {
diff --git a/traditionalApp/pages/liveDetail/service.js b/traditionalApp/pages/liveDetail/service.js
index a8c89c8..fc891fc 100644
--- a/traditionalApp/pages/liveDetail/service.js
+++ b/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}`)
}
diff --git a/traditionalApp/pages/login/login.vue b/traditionalApp/pages/login/login.vue
index ad33f09..67c2a4d 100644
--- a/traditionalApp/pages/login/login.vue
+++ b/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
diff --git a/traditionalApp/pages/mine/mine.vue b/traditionalApp/pages/mine/mine.vue
index 46260e7..0be6d69 100644
--- a/traditionalApp/pages/mine/mine.vue
+++ b/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;
diff --git a/traditionalApp/pages/protocol/protocol.vue b/traditionalApp/pages/protocol/protocol.vue
index d1f58ec..3762124 100644
--- a/traditionalApp/pages/protocol/protocol.vue
+++ b/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 {
diff --git a/traditionalApp/pages/protocol/service.js b/traditionalApp/pages/protocol/service.js
index 2409c27..c65f592 100644
--- a/traditionalApp/pages/protocol/service.js
+++ b/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}`)
}
diff --git a/traditionalApp/pages/watchLive/watchLive.vue b/traditionalApp/pages/watchLive/watchLive.vue
index 22692b5..630680f 100644
--- a/traditionalApp/pages/watchLive/watchLive.vue
+++ b/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 {
diff --git a/traditionalApp/utils/public-request.js b/traditionalApp/utils/public-request.js
new file mode 100644
index 0000000..b30b971
--- /dev/null
+++ b/traditionalApp/utils/public-request.js
@@ -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)
+ },
+ })
+ })
+}
diff --git a/traditionalApp/utils/push.js b/traditionalApp/utils/push.js
index 5a8b1fb..bd58f9b 100644
--- a/traditionalApp/utils/push.js
+++ b/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)
})
}
diff --git a/traditionalApp/utils/request.js b/traditionalApp/utils/request.js
index 7d8e61c..66d15ba 100644
--- a/traditionalApp/utils/request.js
+++ b/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)
--
Gitblit v1.7.1