| New file |
| | |
| | | <!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> |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | @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()); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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)); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | @NotNull(message = "请选择是否首页推荐") |
| | | private Integer isHomeRecommend; |
| | | |
| | | private Integer sort; |
| | | } |
| | |
| | | course.setFavoriteCount(0); |
| | | course.setTotalDurationSeconds(0); |
| | | course.setVideoCount(0); |
| | | course.setSort(0); |
| | | course.setDeleted(0); |
| | | healthCourseMapper.insert(course); |
| | | return detail(course.getId()); |
| | |
| | | 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) { |
| | |
| | | vo.setCoverImg(row.getCoverImg()); |
| | | vo.setStatus(row.getStatus()); |
| | | vo.setIsHomeRecommend(row.getIsHomeRecommend()); |
| | | vo.setSort(defaultNumber(row.getSort())); |
| | | vo.setCreatedAt(row.getCreatedAt()); |
| | | return vo; |
| | | } |
| | |
| | | private String coverImg; |
| | | private Integer status; |
| | | private Integer isHomeRecommend; |
| | | private Integer sort; |
| | | private LocalDateTime createdAt; |
| | | } |
| | |
| | | private String coverImg; |
| | | private Integer status; |
| | | private Integer isHomeRecommend; |
| | | private Integer sort; |
| | | private LocalDateTime createdAt; |
| | | } |
| | |
| | | # 活动到期积分结算扫描间隔(毫秒) |
| | | settlement-interval-ms: 60000 |
| | | auth: |
| | | # APP 登录 token 有效期,默认 30 天;如需永久有效可改为 -1 |
| | | token-timeout-seconds: 2592000 |
| | | sms: |
| | | # 短信验证码统一有效期 300 秒,即 5 分钟 |
| | | expire-seconds: 300 |
| | |
| | | favoriteCount?: number; |
| | | totalDurationSeconds?: number; |
| | | status: number; |
| | | sort?: number; |
| | | expert?: { |
| | | id: number; |
| | | name: string; |
| | |
| | | <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> |
| | |
| | | favoriteCount?: number; |
| | | coverImg?: string; |
| | | status: number; |
| | | sort?: number; |
| | | expert?: { |
| | | id: number; |
| | | name: string; |
| | |
| | | render: (value) => value?.name || '-', |
| | | }, |
| | | { |
| | | title: '排序', |
| | | dataIndex: 'sort', |
| | | width: 90, |
| | | render: (value) => value ?? 0, |
| | | }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'status', |
| | | width: 90, |
| | |
| | | isHomeRecommend: false, |
| | | salePrice: 0, |
| | | baseSalesCount: 0, |
| | | sort: 0, |
| | | summary: '', |
| | | targetUsers: '', |
| | | bannerImgs: [], |
| | |
| | | coverImg: course.coverImg, |
| | | bannerImgs: course.bannerImgs || [], |
| | | baseSalesCount: course.baseSalesCount, |
| | | sort: course.sort ?? 0, |
| | | status: course.status === 1, |
| | | isHomeRecommend: course.isHomeRecommend === 1, |
| | | content: course.content, |
| | |
| | | coverImg, |
| | | bannerImgs, |
| | | content: values.content, |
| | | sort: Number(values.sort || 0), |
| | | status: values.status ? 1 : 0, |
| | | isHomeRecommend: values.isHomeRecommend ? 1 : 0, |
| | | }; |
| | |
| | | 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> |
| | |
| | | { |
| | | "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" : { |
| | |
| | | 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 || {} |
| | | } |
| | | |
| | | /** |
| | | * 拉取健康科普详情接口并更新页面状态。 |
| | |
| | | line-height: 40rpx; |
| | | color: #86909C; |
| | | } |
| | | </style> |
| | | </style> |
| | |
| | | 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}`) |
| | | } |
| | |
| | | </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> |
| | | <!-- 图片指示器 --> |
| | |
| | | </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 { |
| | |
| | | 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) |
| | |
| | | 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 |
| | | } |
| | |
| | | currentSwiperIndex.value = 0 |
| | | } |
| | | syncActiveVideoAfterDetailLoad() |
| | | handlePlayFirstVideoAfterDetailLoad() |
| | | handlePlayLatestVideoAfterDetailLoad() |
| | | } catch { |
| | | showLoadFailedToast() |
| | |
| | | } |
| | | |
| | | /** |
| | | * 课程介绍封面播放按钮:播放最新上传章节。 |
| | | * 首页精选课程播放按钮要求直接播放第一个章节,详情接口返回后再执行。 |
| | | */ |
| | | function playLatestUploadedVideoFromPoster() { |
| | | playLatestUploadedVideo() |
| | | function handlePlayFirstVideoAfterDetailLoad() { |
| | | if (!shouldPlayFirstVideoOnLoad.value) { |
| | | return |
| | | } |
| | | shouldPlayFirstVideoOnLoad.value = false |
| | | playFirstCourseVideo() |
| | | } |
| | | |
| | | /** |
| | | * 课程介绍封面播放按钮:播放第一个章节。 |
| | | */ |
| | | function playFirstCourseVideoFromPoster() { |
| | | playFirstCourseVideo() |
| | | } |
| | | |
| | | function playLatestUploadedVideo() { |
| | |
| | | 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 兜底。 |
| | | */ |
| | |
| | | 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}`) |
| | | } |
| | | |
| | | /** |
| | |
| | | <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 --> |
| | |
| | | </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> |
| | | |
| | | |
| | |
| | | <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> |
| | |
| | | </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 { |
| | |
| | | } |
| | | |
| | | const statusBarHeight = ref(0); |
| | | const scrollY = ref(0); |
| | | const bannerList = ref<HomeBannerItem[]>([]); |
| | | const bannerActiveIndex = ref(0); |
| | | const liveCourseList = ref<HomeLiveCourseItem[]>([]); |
| | |
| | | statusBarHeight.value = res.statusBarHeight || 0; |
| | | }, |
| | | }); |
| | | setTimeout(() => { |
| | | refreshHomeData(); |
| | | }, 0); |
| | | }); |
| | | |
| | | /** |
| | |
| | | } 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'; |
| | | }); |
| | | |
| | | /** |
| | |
| | | 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 = []; |
| | | } |
| | |
| | | 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 = []; |
| | |
| | | } |
| | | refreshing.value = true; |
| | | try { |
| | | await Promise.all([ |
| | | await Promise.allSettled([ |
| | | loadBannerList(), |
| | | loadHomeLiveCourseList(), |
| | | loadFeaturedContent() |
| | |
| | | * 跳转直播详情页。 |
| | | */ |
| | | 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 |
| | | }); |
| | | }; |
| | | |
| | |
| | | * 跳转养生课程详情页。 |
| | | */ |
| | | 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` |
| | | }); |
| | | }; |
| | | |
| | |
| | | * 跳转健康科普详情页。 |
| | | */ |
| | | 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 |
| | | }); |
| | | }; |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | .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; |
| | | } |
| | | } |
| | |
| | | z-index: 10; |
| | | // padding: 0 24rpx; |
| | | padding-top: 24rpx; |
| | | margin-top: -30rpx; |
| | | /* Re-added for overlap with top-bg */ |
| | | } |
| | | |
| | | /* Search Box */ |
| | |
| | | } |
| | | } |
| | | |
| | | .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 { |
| | | |
| | |
| | | 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') |
| | | } |
| | | |
| | | /** |
| | |
| | | <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> |
| | | |
| | |
| | | </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' |
| | |
| | | */ |
| | | 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 |
| | | } |
| | |
| | | } |
| | | // #endif |
| | | return '' |
| | | } |
| | | |
| | | function normalizeRouteParam(value : unknown) { |
| | | if (value === undefined || value === null) { |
| | | return '' |
| | | } |
| | | return String(value).trim() |
| | | } |
| | | |
| | | /** |
| | |
| | | height: 140rpx; |
| | | } |
| | | |
| | | .fullscreen-icon { |
| | | position: absolute; |
| | | right: 24rpx; |
| | | bottom: 24rpx; |
| | | width: 48rpx; |
| | | height: 48rpx; |
| | | } |
| | | } |
| | | |
| | | .scroll-content { |
| | |
| | | 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}`) |
| | | } |
| | | |
| | | /** |
| | |
| | | * 获取 APP 养生直播专家详情。 |
| | | */ |
| | | export const getLiveExpertDetail = (expertId) => { |
| | | return request.get(`/api/app/live-experts/${expertId}`, {}, false, false) |
| | | return publicGet(`/api/app/live-experts/${expertId}`) |
| | | } |
| | |
| | | }) |
| | | return |
| | | } |
| | | saveLoginSession(res.data) |
| | | saveLoginSession(res.data, normalizedPhone) |
| | | uni.showToast({ |
| | | title: '登录成功', |
| | | icon: 'success' |
| | |
| | | /** |
| | | * 将登录响应写入本地缓存,供后续请求与页面读取。 |
| | | */ |
| | | 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 |
| | |
| | | </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> |
| | |
| | | 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' |
| | | |
| | |
| | | icon: string |
| | | url: string |
| | | iconClass?: string |
| | | requiresLogin?: boolean |
| | | } |
| | | |
| | | interface NoticeSummaryItem { |
| | |
| | | |
| | | 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' |
| | |
| | | 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' }, |
| | | ] |
| | |
| | | * 将接口或缓存中的用户信息归一化为页面可直接消费的结构。 |
| | | */ |
| | | 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), |
| | |
| | | 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({ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 未登录访问个人资产类页面时,先进入登录页。 |
| | | */ |
| | | 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 |
| | | }) |
| | | } |
| | | |
| | |
| | | display: flex; |
| | | justify-content: flex-end; |
| | | align-items: center; |
| | | padding-top: 20rpx; |
| | | padding-top: 80rpx; |
| | | gap: 32rpx; |
| | | |
| | | .notice-action { |
| | |
| | | .user-info { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-top: 40rpx; |
| | | margin-top: 24rpx; |
| | | |
| | | .avatar-wrap { |
| | | position: relative; |
| | |
| | | <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> |
| | |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .protocol-content { |
| | | padding: 32rpx; |
| | | } |
| | | |
| | | .rich-content { |
| | | width: 100%; |
| | | font-size: 28rpx; |
| | | line-height: 1.8; |
| | | color: #333; |
| | | } |
| | | |
| | | .expert-page { |
| | |
| | | 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}`) |
| | | } |
| | |
| | | <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"> |
| | |
| | | </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"> |
| | |
| | | </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'; |
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | |
| | | 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 !== '') { |
| | |
| | | return '直播已结束,请完成答题' |
| | | } |
| | | if (!liveDetail.value.videoUrl) { |
| | | return '直播视频准备中' |
| | | return liveDetail.value.displayStatus === 'ENDED' ? '直播已结束,请完成答题' : '直播视频准备中' |
| | | } |
| | | return '当前直播暂不可观看' |
| | | }) |
| | |
| | | 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 |
| | | }) |
| | | |
| | | /** |
| | |
| | | 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, |
| | |
| | | } |
| | | } |
| | | |
| | | 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, |
| | | }) |
| | | }) |
| | | |
| | | /** |
| | |
| | | onHide(() => { |
| | | clearPlaybackStartupTimer() |
| | | clearPlaybackSyncTimer() |
| | | clearManualPlayLoaderTimer() |
| | | clearAutoPlayLoaderTimers() |
| | | isManualPlayLoaderVisible.value = false |
| | | clearQuizAvailabilityTimer() |
| | | submitCurrentWatchSegment() |
| | | }) |
| | |
| | | onUnload(() => { |
| | | clearPlaybackStartupTimer() |
| | | clearPlaybackSyncTimer() |
| | | clearManualPlayLoaderTimer() |
| | | clearAutoPlayLoaderTimers() |
| | | isManualPlayLoaderVisible.value = false |
| | | clearQuizAvailabilityTimer() |
| | | submitCurrentWatchSegment() |
| | | }) |
| | |
| | | /** |
| | | * 拉取直播详情并回填观看页视频信息。 |
| | | */ |
| | | 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 |
| | |
| | | await nextTick() |
| | | initVideoContext() |
| | | startAutoPlayLoaderIfNeeded() |
| | | if (shouldResumeIfPaused && isVideoPlayable.value) { |
| | | shouldResyncOnNextPlay.value = true |
| | | retryPlayback() |
| | | } |
| | | if (hasLoginToken() && activeSessionId.value) { |
| | | await loadQuizStatus() |
| | | } |
| | |
| | | function resetVideoPlaybackState() { |
| | | clearPlaybackStartupTimer() |
| | | clearPlaybackSyncTimer() |
| | | clearManualPlayLoaderTimer() |
| | | clearAutoPlayLoaderTimers() |
| | | currentWatchStartedAt.value = 0 |
| | | isVideoReady.value = false |
| | |
| | | playbackFailed.value = false |
| | | pendingPlaybackSync.value = false |
| | | playbackSyncAttempts.value = 0 |
| | | isManualPlayLoaderVisible.value = false |
| | | shouldResyncOnNextPlay.value = false |
| | | lastMediaCurrentTime.value = 0 |
| | | autoPlayLoaderPercent.value = 0 |
| | | isAutoPlayLoaderVisible.value = false |
| | |
| | | } |
| | | |
| | | /** |
| | | * 清理手动播放加载遮罩的进度定时器。 |
| | | */ |
| | | 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() { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 进入伪直播后先展示加载环,再进入横屏全屏并按当前直播进度播放。 |
| | | * 进入伪直播后先展示加载环,加载完成后交给用户手动播放。 |
| | | */ |
| | | function startAutoPlayLoaderIfNeeded() { |
| | | if (!isLoaderGatedPlayback.value || !isVideoPlayable.value || hasStartedReadonlyAutoPlay.value) { |
| | |
| | | if (elapsed >= AUTO_PLAY_LOADER_DURATION) { |
| | | clearAutoPlayLoaderTimers() |
| | | autoPlayLoaderPercent.value = AUTO_PLAY_LOADER_TARGET |
| | | hasQueuedReadonlyAutoPlay.value = true |
| | | requestLandscapeFullscreenIfNeeded() |
| | | tryStartPlayback() |
| | | isAutoPlayLoaderVisible.value = false |
| | | } |
| | | }, 60) |
| | | } |
| | |
| | | isStartingPlayback.value = false |
| | | playbackFailed.value = true |
| | | isVideoPaused.value = true |
| | | clearManualPlayLoaderTimer() |
| | | isManualPlayLoaderVisible.value = false |
| | | } |
| | | }, 1200) |
| | | } |
| | |
| | | } |
| | | return |
| | | } |
| | | syncPlaybackWhenReady(true) |
| | | isVideoPaused.value = true |
| | | if (pendingPlaybackSync.value || hasQueuedReadonlyAutoPlay.value) { |
| | | syncPlaybackWhenReady(hasQueuedReadonlyAutoPlay.value) |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 视频区域点击仅保留给 App 端暂停/恢复,分享页不提供手动播放按钮。 |
| | | */ |
| | | function handleVideoWrapperTap() { |
| | | if (isReadonlyDisplayMode) { |
| | | if (!isReadonlyDisplayMode) { |
| | | return |
| | | } |
| | | togglePlayback() |
| | | } |
| | | |
| | | /** |
| | |
| | | /** |
| | | * 手动重试当前视频播放,复用统一的自动播放拉起逻辑。 |
| | | */ |
| | | 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() |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | */ |
| | | 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({ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 记录全屏切换状态,驱动自绘按钮文案与退出逻辑。 |
| | | * 记录原生全屏状态,驱动自绘全屏按钮。 |
| | | */ |
| | | function handleFullscreenChange(event : VideoFullscreenChangeEvent) { |
| | | isVideoFullscreen.value = !!(event?.detail?.fullScreen || event?.detail?.fullscreen) |
| | | } |
| | | |
| | | /** |
| | | * 使用自绘按钮触发全屏,避免依赖原生播放控制条。 |
| | | * 使用原生 video 全屏,避免伪全屏放大页面;原生 controls 仍保持关闭。 |
| | | */ |
| | | function toggleFullscreen() { |
| | | if (!videoContext) { |
| | | initVideoContext() |
| | | } |
| | | if (!videoContext) { |
| | | return |
| | | } |
| | |
| | | } |
| | | videoContext.requestFullScreen({ |
| | | direction: 90, |
| | | success: () => { |
| | | isVideoFullscreen.value = true |
| | | }, |
| | | }) |
| | | } |
| | | </script> |
| | |
| | | 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 { |
| New file |
| | |
| | | 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) |
| | | }, |
| | | }) |
| | | }) |
| | | } |
| | |
| | | return null |
| | | } |
| | | if (typeof jpushModule.setLoggerEnable === 'function') { |
| | | jpushModule.setLoggerEnable(true) |
| | | jpushModule.setLoggerEnable(false) |
| | | } |
| | | if (typeof jpushModule.initJPushService === 'function') { |
| | | jpushModule.initJPushService() |
| | |
| | | if (typeof jpushModule.addConnectEventListener === 'function') { |
| | | jpushModule.addConnectEventListener((result) => { |
| | | const connectEnable = !!(result && result.connectEnable) |
| | | console.log('极光连接状态', result) |
| | | if (!connectEnable) { |
| | | return |
| | | } |
| | |
| | | } |
| | | if (typeof jpushModule.addNotificationListener === 'function') { |
| | | jpushModule.addNotificationListener((result) => { |
| | | console.log('48948949841weqwe', result); |
| | | handleJPushNotification(result) |
| | | }) |
| | | } |
| | |
| | | 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 = [] |
| | |
| | | } |
| | | |
| | | /** |
| | | * 后端部分已登录接口会以业务错误返回“用户不存在/已禁用”,这里统一视为登录态失效。 |
| | | */ |
| | | function isSessionInvalidPayload(payload) { |
| | | if (!getStoredToken()) { |
| | | return false |
| | | } |
| | | const message = resolveMessage(payload).trim() |
| | | return SESSION_INVALID_MESSAGES.includes(message) |
| | | } |
| | | |
| | | /** |
| | | * 读取当前语言请求头值。 |
| | | */ |
| | | function resolveLangHeader() { |
| | |
| | | * 构造当前请求头。 |
| | | */ |
| | | 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)) |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | if (payload.code === 502 || payload.code === 501) { |
| | | showErrorToast(payload) |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (isSessionInvalidPayload(payload)) { |
| | | handleUnauthorized(payload) |
| | | resolve(payload) |
| | | return |
| | | } |
| | |
| | | * 判断当前请求是否需要在发送前尝试刷新 token。 |
| | | */ |
| | | function shouldAttemptTokenRefresh(url) { |
| | | if (isPublicAppUrl(url)) { |
| | | return false |
| | | } |
| | | const token = getStoredToken() |
| | | if (!token || !hasBoundPhone()) { |
| | | return false |
| | |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (UNAUTHORIZED_CODES.includes(payload.code)) { |
| | | if (UNAUTHORIZED_CODES.includes(payload.code) || isSessionInvalidPayload(payload)) { |
| | | handleUnauthorized(payload) |
| | | } |
| | | reject(payload) |