training-detail.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. <template>
  2. <view class="container">
  3. <scroll-view class="scroll-body" scroll-y :show-scrollbar="false" :enhanced="true">
  4. <!-- 视频/直播封面区域 -->
  5. <view v-if="pageType === 'video' || pageType === 'live'" class="media-banner">
  6. <!-- 视频播放器 -->
  7. <video
  8. v-if="isPlaying && mediaInfo.videoUrl"
  9. id="myVideo"
  10. class="video-player"
  11. :src="mediaInfo.videoUrl"
  12. autoplay
  13. controls
  14. @fullscreenchange="onFullscreenChange"
  15. ></video>
  16. <!-- 封面展示 -->
  17. <template v-else>
  18. <image :src="trainingInfo.cover || '/static/images/assess_cover.svg'" class="banner-img" mode="aspectFill"></image>
  19. <!-- 中央播放按钮 -->
  20. <view v-if="pageType === 'video'" class="play-btn-wrap" @click="playVideo">
  21. <view class="play-circle">
  22. <view class="play-triangle"></view>
  23. </view>
  24. </view>
  25. <!-- 底部信息栏 -->
  26. <view class="banner-info-bar">
  27. <view class="b-left">
  28. <view class="play-mini-triangle"></view>
  29. <text>{{ mediaInfo.views || '95万' }}</text>
  30. </view>
  31. <view class="b-right">
  32. <template v-if="pageType === 'video'">
  33. <image src="/static/icons/time.svg" class="b-icon" style="filter: brightness(0) invert(1);"></image>
  34. <text>{{ mediaInfo.duration || '10:28' }}</text>
  35. </template>
  36. <template v-else>
  37. <image src="/static/icons/user.svg" class="b-icon" style="filter: brightness(0) invert(1);"></image>
  38. <text>{{ mediaInfo.spectators || '1234567890' }}</text>
  39. </template>
  40. </view>
  41. </view>
  42. </template>
  43. </view>
  44. <view class="content">
  45. <!-- 加载状态 -->
  46. <view v-if="loading" class="loading-state">
  47. <text class="loading-text">加载中...</text>
  48. </view>
  49. <!-- 标题区域 -->
  50. <view v-if="!loading" class="header-section">
  51. <text :class="['title', (pageType === 'video' || pageType === 'live') ? 'video-title' : '']">{{ trainingInfo.title || '培训详情' }}</text>
  52. <view v-if="pageType === 'offline'" class="info-list">
  53. <view class="info-item" v-if="trainingInfo.location">
  54. <image src="/static/icons/location.svg" class="i-icon"></image>
  55. <text class="i-text">{{ trainingInfo.location }}</text>
  56. </view>
  57. <view class="info-item" v-if="trainingInfo.organizer">
  58. <image src="/static/icons/user.svg" class="i-icon"></image>
  59. <text class="i-text">主办单位:{{ trainingInfo.organizer }}</text>
  60. </view>
  61. <view class="info-item" v-if="trainingInfo.trainingTime">
  62. <image src="/static/icons/time.svg" class="i-icon"></image>
  63. <text class="i-text">培训时间:{{ trainingInfo.trainingTime }}</text>
  64. </view>
  65. <view class="info-item" v-if="trainingInfo.deadline">
  66. <image src="/static/icons/time.svg" class="i-icon"></image>
  67. <text class="i-text">截止时间:{{ trainingInfo.deadline }}</text>
  68. <text class="ending-tag" v-if="trainingInfo.isEnding">即将截止</text>
  69. </view>
  70. </view>
  71. <view v-else class="lecturer-info">
  72. <text class="l-name">讲师:{{ trainingInfo.lecturer || '待定' }}</text>
  73. </view>
  74. </view>
  75. <view class="divider"></view>
  76. <!-- 详情区域 -->
  77. <view v-if="!loading" class="detail-section">
  78. <text class="section-title">培训详情</text>
  79. <view class="tag-row" v-if="trainingInfo.tags && trainingInfo.tags.length > 0">
  80. <text class="t-tag" v-for="(tag, idx) in trainingInfo.tags" :key="idx">{{ tag }}</text>
  81. </view>
  82. <view class="rich-content">
  83. <view class="para-item" v-if="trainingInfo.description">
  84. <text class="p-label">培训描述:</text>
  85. <text class="p-text">{{ trainingInfo.description }}</text>
  86. </view>
  87. <view class="para-item" v-if="trainingInfo.requirements">
  88. <text class="p-label">参与要求:</text>
  89. <text class="p-text">{{ trainingInfo.requirements }}</text>
  90. </view>
  91. <view class="para-item" v-if="trainingInfo.benefits">
  92. <text class="p-label">培训收益:</text>
  93. <text class="p-text">{{ trainingInfo.benefits }}</text>
  94. </view>
  95. </view>
  96. </view>
  97. <view v-if="pageType === 'offline'" class="divider"></view>
  98. <!-- 地址区域 (仅线下) -->
  99. <view v-if="pageType === 'offline'" class="address-section">
  100. <view class="section-title">线下培训地址</view>
  101. <view class="map-wrapper">
  102. <map
  103. class="map-view"
  104. :latitude="latitude"
  105. :longitude="longitude"
  106. :markers="markers"
  107. :scale="16"
  108. show-location
  109. ></map>
  110. </view>
  111. </view>
  112. <!-- 到底啦提示 -->
  113. <view class="end-of-page">
  114. <view class="line"></view>
  115. <text class="end-text">已到底啦~</text>
  116. <view class="line"></view>
  117. </view>
  118. <view class="bottom-placeholder"></view>
  119. </view>
  120. </scroll-view>
  121. <!-- 底部操作栏 (视频类型不展示) -->
  122. <view v-if="pageType !== 'video'" class="bottom-bar">
  123. <!-- 线下培训状态 -->
  124. <template v-if="pageType === 'offline'">
  125. <button v-if="regStatus === 'consult'" class="action-btn primary" @click="handleConsult">咨询</button>
  126. <button v-if="regStatus === 'enrolled'" class="action-btn disabled">已报名</button>
  127. <button v-if="regStatus === 'finished'" class="action-btn disabled">培训结束</button>
  128. </template>
  129. <!-- 直播培训状态 -->
  130. <template v-else-if="pageType === 'live'">
  131. <button v-if="liveStatus === 'streaming' || liveStatus === 'upcoming'" class="action-btn primary" @click="watchLive">进入直播间</button>
  132. <button v-else-if="liveStatus === 'not-started'" class="action-btn disabled">直播还未开始</button>
  133. <button v-else-if="liveStatus === 'finished'" class="action-btn primary" @click="watchReplay">观看回放</button>
  134. <button v-else class="action-btn primary" @click="watchLive">进入直播间</button>
  135. </template>
  136. </view>
  137. </view>
  138. </template>
  139. <script setup>
  140. import { ref } from 'vue';
  141. import { onLoad } from '@dcloudio/uni-app';
  142. import { createOrGetSession } from '../../api/message.js';
  143. import { getTrainingDetail } from '../../api/assessment.js';
  144. const pageType = ref('offline'); // offline, video, live
  145. const regStatus = ref('consult');
  146. const liveStatus = ref('');
  147. const mediaInfo = ref({});
  148. const isPlaying = ref(false);
  149. const loading = ref(true);
  150. const trainingId = ref(null);
  151. const trainingInfo = ref({
  152. title: '',
  153. location: '',
  154. organizer: '',
  155. trainingTime: '',
  156. deadline: '',
  157. isEnding: false,
  158. tags: [],
  159. description: '',
  160. requirements: '',
  161. benefits: ''
  162. });
  163. // 地图相关数据
  164. const latitude = ref(31.22863);
  165. const longitude = ref(121.45039);
  166. const markers = ref([{
  167. id: 1,
  168. latitude: 31.22863,
  169. longitude: 121.45039,
  170. title: 'SOHO东海广场',
  171. iconPath: '/static/icons/location.svg',
  172. width: 32,
  173. height: 32,
  174. callout: {
  175. content: '上海静安区SOHO东海广场1209',
  176. color: '#333333', fontSize: 12, borderRadius: 8, padding: 8, bgColor: '#ffffff', display: 'ALWAYS'
  177. }
  178. }]);
  179. onLoad((options) => {
  180. if (options.id) {
  181. trainingId.value = options.id;
  182. loadTrainingDetail(options.id);
  183. }
  184. if (options.type) pageType.value = options.type;
  185. if (options.status) liveStatus.value = options.status;
  186. // 设置页面标题
  187. if (pageType.value === 'video') {
  188. uni.setNavigationBarTitle({ title: '视频培训详情' });
  189. } else if (pageType.value === 'live') {
  190. uni.setNavigationBarTitle({ title: '直播培训详情' });
  191. } else {
  192. uni.setNavigationBarTitle({ title: '培训详情' });
  193. }
  194. mediaInfo.value = {
  195. views: options.views,
  196. duration: options.duration,
  197. spectators: options.spectators
  198. };
  199. if (options.regSuccess) regStatus.value = 'enrolled';
  200. if (options.isFinished) regStatus.value = 'finished';
  201. });
  202. // 加载培训详情数据
  203. const loadTrainingDetail = async (id) => {
  204. try {
  205. loading.value = true;
  206. const res = await getTrainingDetail(id);
  207. if (res.code === 200 && res.data) {
  208. const data = res.data;
  209. trainingInfo.value = {
  210. title: data.trainingName || data.title || '培训详情',
  211. location: data.location || '线上培训',
  212. organizer: data.organizer || data.organizerName || '平台推荐',
  213. trainingTime: formatTrainingTime(data.startTime, data.endTime),
  214. deadline: formatDeadline(data.deadline || data.registrationDeadline),
  215. isEnding: checkIsEnding(data.deadline || data.registrationDeadline),
  216. tags: data.tags ? data.tags.split(',').filter(tag => tag.trim()) : [],
  217. description: data.description || data.remark || '',
  218. requirements: data.requirements || '',
  219. benefits: data.benefits || '',
  220. price: data.price || '0.00',
  221. cover: data.coverImage || '/static/images/assess_cover.svg'
  222. };
  223. // 处理媒体信息
  224. if (data.videoUrl) {
  225. mediaInfo.value.videoUrl = data.videoUrl;
  226. }
  227. if (data.views) {
  228. mediaInfo.value.views = data.views;
  229. }
  230. if (data.duration) {
  231. mediaInfo.value.duration = data.duration;
  232. }
  233. } else {
  234. uni.showToast({ title: '获取培训详情失败', icon: 'none' });
  235. }
  236. } catch (err) {
  237. console.error('获取培训详情失败:', err);
  238. uni.showToast({ title: '网络错误,请重试', icon: 'none' });
  239. } finally {
  240. loading.value = false;
  241. }
  242. };
  243. // 格式化培训时间
  244. const formatTrainingTime = (startTime, endTime) => {
  245. if (!startTime) return '';
  246. const formatDate = (dateStr) => {
  247. const date = new Date(dateStr);
  248. const year = date.getFullYear();
  249. const month = String(date.getMonth() + 1).padStart(2, '0');
  250. const day = String(date.getDate()).padStart(2, '0');
  251. return `${year}-${month}-${day}`;
  252. };
  253. if (endTime && startTime !== endTime) {
  254. return `${formatDate(startTime)} 至 ${formatDate(endTime)}`;
  255. }
  256. return formatDate(startTime);
  257. };
  258. // 格式化截止时间
  259. const formatDeadline = (deadline) => {
  260. if (!deadline) return '';
  261. const date = new Date(deadline);
  262. const year = date.getFullYear();
  263. const month = String(date.getMonth() + 1).padStart(2, '0');
  264. const day = String(date.getDate()).padStart(2, '0');
  265. const hours = String(date.getHours()).padStart(2, '0');
  266. const minutes = String(date.getMinutes()).padStart(2, '0');
  267. return `${year}-${month}-${day} ${hours}:${minutes}`;
  268. };
  269. // 检查是否即将截止
  270. const checkIsEnding = (deadline) => {
  271. if (!deadline) return false;
  272. const now = new Date();
  273. const deadlineDate = new Date(deadline);
  274. const timeDiff = deadlineDate.getTime() - now.getTime();
  275. const daysDiff = timeDiff / (1000 * 3600 * 24);
  276. return daysDiff > 0 && daysDiff <= 3; // 3天内即将截止
  277. };
  278. const playVideo = () => {
  279. isPlaying.value = true;
  280. // 延迟确保 DOM 渲染完成
  281. setTimeout(() => {
  282. const videoContext = uni.createVideoContext('myVideo');
  283. videoContext.play();
  284. // 显式触发全屏
  285. videoContext.requestFullScreen({
  286. direction: 90 // 横屏全屏
  287. });
  288. }, 300);
  289. };
  290. const onFullscreenChange = (e) => {
  291. // 全屏状态改变时的回调
  292. if (!e.detail.fullScreen) {
  293. // 退出全屏的处理
  294. }
  295. };
  296. const handleConsult = async () => {
  297. try {
  298. uni.showLoading({ title: '正在连接客服...' });
  299. const userInfo = uni.getStorageSync('userInfo') || {};
  300. const userId = userInfo.studentId || null;
  301. const userName = userInfo.name || '用户';
  302. const userAvatar = userInfo.avatarUrl || '/static/images/user_avatar.svg';
  303. const res = await createOrGetSession({
  304. sessionType: 1,
  305. fromUserId: userId,
  306. fromUserName: userName,
  307. fromUserAvatar: userAvatar,
  308. sourceId: 'training_' + (trainingData.value?.id || trainingId.value)
  309. });
  310. uni.hideLoading();
  311. if (res.data) {
  312. const session = res.data;
  313. const title = encodeURIComponent(trainingInfo.value.title || '');
  314. const cover = encodeURIComponent(trainingInfo.value.cover || '');
  315. const price = trainingInfo.value.price || '0.00';
  316. uni.navigateTo({
  317. url: `/pages/chat/chat?sessionId=${session.sessionId}&sessionNo=${session.sessionNo || ''}&fromUserId=${userId || ''}&userName=${encodeURIComponent(userName)}&type=training&title=${title}&cover=${cover}&trainingId=${trainingId.value || ''}&price=${price}`
  318. });
  319. } else {
  320. uni.showToast({ title: '创建会话失败', icon: 'none' });
  321. }
  322. } catch (err) {
  323. uni.hideLoading();
  324. console.error('创建会话失败:', err);
  325. uni.showToast({ title: '连接失败,请重试', icon: 'none' });
  326. }
  327. };
  328. const watchLive = () => uni.showToast({ title: '正在进入直播间...', icon: 'loading' });
  329. const subscribeLive = () => {
  330. uni.showToast({ title: '预约成功', icon: 'success' });
  331. liveStatus.value = 'subscribed';
  332. };
  333. const watchReplay = () => {
  334. pageType.value = 'video'; // 切换为视频模式播放回播
  335. playVideo();
  336. };
  337. </script>
  338. <style lang="scss" scoped>
  339. .container { width: 100%; height: 100vh; background: #FFF; display: flex; flex-direction: column; }
  340. .scroll-body { flex: 1; height: 0; }
  341. /* 视频/直播 Banner */
  342. .media-banner {
  343. width: 100%; height: 420rpx; position: relative; background: #000;
  344. .banner-img { width: 100%; height: 100%; }
  345. .video-player { width: 100%; height: 100%; }
  346. .play-btn-wrap {
  347. position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  348. .play-circle {
  349. width: 110rpx; height: 110rpx; background: rgba(0,0,0,0.4); border: 4rpx solid #FFF; border-radius: 50%;
  350. display: flex; align-items: center; justify-content: center;
  351. .play-triangle {
  352. width: 0; height: 0;
  353. border-left: 36rpx solid #FFF; border-top: 24rpx solid transparent; border-bottom: 24rpx solid transparent;
  354. margin-left: 10rpx;
  355. }
  356. }
  357. }
  358. .banner-info-bar {
  359. position: absolute; left: 0; right: 0; bottom: 0; height: 80rpx;
  360. background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 100%);
  361. display: flex; justify-content: space-between; align-items: center; padding: 0 40rpx;
  362. color: #FFF; font-size: 26rpx;
  363. .b-left, .b-right { display: flex; align-items: center; gap: 12rpx; }
  364. .b-icon { width: 28rpx; height: 28rpx; }
  365. .play-mini-triangle {
  366. width: 0; height: 0;
  367. border-left: 12rpx solid #FFF; border-top: 8rpx solid transparent; border-bottom: 8rpx solid transparent;
  368. }
  369. }
  370. }
  371. .content { padding: 30rpx 40rpx; }
  372. .loading-state {
  373. display: flex; justify-content: center; align-items: center;
  374. padding: 80rpx 0;
  375. .loading-text { font-size: 28rpx; color: #999; }
  376. }
  377. .header-section {
  378. .title { font-size: 48rpx; font-weight: bold; color: #1A1A1A; display: block; margin-bottom: 30rpx; }
  379. .video-title { font-size: 38rpx; } /* 视频标题调小 */
  380. .info-list {
  381. display: flex; flex-direction: column; gap: 20rpx;
  382. .info-item {
  383. display: flex; align-items: center;
  384. .i-icon { width: 32rpx; height: 32rpx; margin-right: 20rpx; opacity: 0.5; }
  385. .i-text { font-size: 28rpx; color: #666; flex: 1; }
  386. .ending-tag { font-size: 24rpx; color: #FF4D4F; margin-left: 20rpx; font-weight: bold; }
  387. }
  388. }
  389. .lecturer-info {
  390. display: flex; align-items: center; font-size: 30rpx; color: #888;
  391. .l-name { font-weight: 500; }
  392. }
  393. }
  394. .divider { height: 1rpx; background: #F0F0F0; margin: 40rpx 0; }
  395. .section-title { font-size: 34rpx; font-weight: bold; color: #1A1A1A; margin-bottom: 30rpx; display: block; }
  396. .detail-section {
  397. .tag-row {
  398. display: flex; flex-wrap: wrap; gap: 16rpx; margin-bottom: 40rpx;
  399. .t-tag { font-size: 24rpx; color: #666; background: #F5F7FA; padding: 10rpx 24rpx; border-radius: 10rpx; }
  400. }
  401. .rich-content {
  402. .para-item {
  403. margin-bottom: 30rpx;
  404. .p-label { font-size: 30rpx; font-weight: bold; color: #1A1A1A; display: block; margin-bottom: 12rpx; }
  405. .p-text { font-size: 28rpx; color: #666; line-height: 1.6; display: block; white-space: pre-wrap; }
  406. }
  407. }
  408. }
  409. .address-section {
  410. .map-wrapper {
  411. position: relative; width: 100%; height: 360rpx; border-radius: 12rpx; overflow: hidden;
  412. box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
  413. .map-view { width: 100%; height: 100%; }
  414. }
  415. }
  416. .end-of-page {
  417. display: flex; align-items: center; justify-content: center; padding: 60rpx 0 20rpx;
  418. .line { width: 60rpx; height: 1rpx; background: #EEE; margin: 0 20rpx; }
  419. .end-text { font-size: 24rpx; color: #BBB; }
  420. }
  421. .bottom-placeholder { height: 160rpx; }
  422. .bottom-bar {
  423. position: fixed; left: 0; right: 0; bottom: 0; background: #FFF; padding: 20rpx 40rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
  424. box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05); z-index: 1000;
  425. .action-btn {
  426. width: 100%; height: 90rpx; line-height: 90rpx; border-radius: 45rpx; font-size: 32rpx; font-weight: bold;
  427. &::after { border: none; }
  428. &.primary { background: #1F6CFF; color: #FFF; box-shadow: 0 6rpx 20rpx rgba(31, 108, 255, 0.3); }
  429. &.disabled { background: #D6E8FF; color: #1F6CFF; }
  430. }
  431. }
  432. </style>