绿满眶商城微信小程序-uniapp
pages/tabbar/index/home.vue
@@ -9,17 +9,28 @@
      @change="onSwiperChange"
    >
      <swiper-item v-for="(item, index) in videoList" :key="item.id">
      <!-- 播放按钮(仅当视频暂停时显示) -->
      <view
        class="play-icon"
        @click="togglePlay(index)"
        v-if="!currentVideoIsPlaying"
      >
        <image src="/static/video/play.png" style="width: 45px;height: 45px" mode="aspectFit"></image>
      </view>
        <video 
          :id="'video'+index"
          :src="item.url"
        :ref="'video'+index"
          :src="item.videoUrl"
          :autoplay="currentIndex === index"
          :controls="false"
          :loop="true"
        :object-fit="item.objectFit"
          class="video-item"
          @play="onPlay(index)"
          @play="onPlay(item.id, index)"
          @pause="onPause(index)"
          @ended="onEnded(index)"
        @click="togglePlay(index)"
        @timeupdate="onTimeUpdate($event)"
        ></video>
      
      <!-- 悬挂商品链接层 -->
@@ -27,7 +38,7 @@
         <view class="goods-link">
           <view class="goods-container">
             <!-- 商品图片 -->
             <image class="goods-image" :src="item.goods.image" mode="aspectFill"></image>
             <image class="goods-image" :src="item.goods.imageUrl" mode="aspectFill"></image>
             
             <!-- 商品信息 -->
             <view class="goods-info">
@@ -36,7 +47,7 @@
                 <text class="current-price">¥{{item.goods.price}}</text>
                 <text class="original-price" v-if="item.goods.originalPrice">¥{{item.goods.originalPrice}}</text>
               </view>
               <text class="sales-count">{{item.goods.sales}}人已购</text>
               <text class="sales-count">{{item.goods.saleNum}}人已购</text>
             </view>
             
             <!-- 购买按钮 -->
@@ -51,11 +62,11 @@
        <!-- 视频信息层 -->
        <view class="video-info">
        <view>
           <text class="video-author">@{{item.author}}</text>
           <text class="video-author">@{{item.authorName}}</text>
        </view>
          <view style="width: 100%;word-wrap: break-word;white-space: normal;overflow-wrap: break-word;">
           <text class="video-title">{{item.title}}</text>
           <text class="video-tag" v-for="(tag, index) in item.tags" :key="tag">#{{tag}}</text>
           <text class="video-tag" v-for="(tag, index) in item.tagList" :key="tag">#{{tag.tagName}}</text>
        </view>
        </view>
        
@@ -64,90 +75,268 @@
         <view class="avatar-container">
            <image class="avatar" :src="item.authorAvatar" mode="aspectFill"></image>
            <!-- 关注图标 - 使用绝对定位 -->
            <view class="follow-icon">
            <view v-if="!item.subscribeThisAuthor" class="follow-icon" @click="subscribeAuth(index, item.authorId)">
             <text class="iconfont">&#xe629;</text>
            </view>
         </view>
          <view class="action-item" @click="toggleCollect(item)">
            <!-- <image :src="item.isCollected ? '/static/collected.png' : '/static/collect.png'"></image> -->
          <view class="action-item" @click="toggleCollect(item, index)">
         <text class="iconfont" v-if="item.collected">&#xe605;</text>
         <text class="iconfont" v-else>&#xe601;</text>
         <text style="font-size: 10px;font-weight: lighter;">{{item.collectCount}}</text>
         <text style="font-size: 10px;font-weight: lighter;">{{item.collectNum}}</text>
          </view>
         <view class="action-item" @click="showComments(item)">
            <text class="iconfont">&#xe7f7;</text>
            <text style="font-size: 10px;font-weight: lighter;">{{item.commentCount}}</text>
            <text style="font-size: 10px;font-weight: lighter;">{{item.commentNum}}</text>
          </view>
        </view>
      </swiper-item>
    </swiper>
   <!-- 评论弹窗 -->
   <uni-popup ref="commentPopup" type="bottom" :is-mask-click="true" @maskClick="closeCommentPopup">
     <view class="comment-popup">
       <view class="popup-header">
         <text class="popup-title">评论({{commentsTotal}})</text>
         <text class="iconfont close-icon" @click="closeCommentPopup">&#xe675;</text>
       </view>
       <scroll-view class="comment-list" scroll-y :show-scrollbar="false" @scrolltolower="getCommentPage">
         <view v-if="commentLoading" class="loading">
           <uni-load-more status="loading"></uni-load-more>
         </view>
         <view v-else-if="comments.length === 0" class="empty">
           暂无评论,快来发表第一条评论吧~
         </view>
         <view v-else class="comment-item" v-for="comment in comments" :key="comment.id">
           <image class="avatar" :src="comment.userAvatar || '/static/default-avatar.png'"></image>
           <view class="comment-content">
             <text class="nickname">{{comment.userNickname}}</text>
             <text class="content">{{comment.commentContent}}</text>
             <text class="time">{{formatTime(comment.createTime)}}</text>
           </view>
         </view>
       </scroll-view>
       <view class="comment-input-area">
         <input
           class="comment-input"
           v-model="commentForm.commentContent"
           placeholder="写下你的评论..."
           placeholder-class="placeholder"
         />
         <button class="submit-btn" @click="submitComment">发送</button>
       </view>
     </view>
   </uni-popup>
   <custom-tabbar bgColor="#333333" selected="index" selectedTextColor="#ffffff"></custom-tabbar>
  </view>
</template>
<script>
import { getRecommendVideos, savePlayRecord, subscribe, getVideoComments, addVideoComment } from "@/api/video.js";
import { changeCollect } from "@/api/collect.js";
export default {
  data() {
    return {
     isFullScreen: false,
     windowHeight: 0,
      currentIndex: 0, // 当前播放的视频索引
      videoList: [
        {
            url: 'http://vjs.zencdn.net/v/oceans.mp4',
            objectFit: 'contain',
            title: '我了个',
            author: 'xp',
            authorAvatar: 'https://picsum.photos/200/200?random=2',
            collected: true,
            commentCount: 12,
            collectCount: 45,
            tags: ["五一", "爱美食", "士大夫速度和粉红色的恢复速度的口袋空空"],
            goods: {
               name: '推流',
               price: '10',
               originalPrice: '48.9',
               sales: 1988,
               image: 'https://picsum.photos/200/200?random=2'
            }
         },
        {
           url: 'https://videos.pexels.com/video-files/30900524/13210612_1080_1920_30fps.mp4',
           objectFit: 'cover',
            title: '我了个',
            author: 'xp',
             authorAvatar: 'https://picsum.photos/200/200?random=2',
              collected: false,
               commentCount: 6,
               collectCount: 45,
                tags: ["我喜欢"],
                goods: {
                               name: '推流',
                               price: '10',
                               originalPrice: '48.9',
                               sales: 1988,
                              image: 'https://picsum.photos/200/200?random=2'
                }
          },
     ],   // 视频列表数据
      videoContexts: [], // 视频上下文对象集合
      loading: false,  // 是否正在加载
      page: 1,         // 当前页码
      pageSize: 10     // 每页数量
      commentNoMore: false, // 是否还有更多评论
      commentQuery: {
         pageNumber: 1,
         pageSize: 5,
         videoId: '',
         masterCommentId: ''
      },
      commentForm: { // 评论表单数据
         id: null,
         videoId: null,
         commentContent: '',
         replyId: null
      },
      comments: [],            // 评论列表
      commentsTotal: 0,            // 评论总条数
      commentLoading: false,   // 评论加载状态
      startHidenTime: 0, // 记录切换至其它页面的时间,用于计算视频观看时间减去的部分
      totalHidenTime: 0, // 总共隐藏页面的时间
      startPauseTime: 0, // 开始暂停的时间
      totalPauseTime: 0, // 总共暂停的时间
      playRecord: {
         videoId: null,
         viewDuration: 0, // 这个视频总共观看了多久
         playAt: 0 ,// 这个视频播放到哪了
         startPlayTime: 0 // 这个视频从什么时候开始播放的
      },
      currentVideoIsPlaying: true, // 当前视频是否正在播放
      isFullScreen: false,
      windowHeight: 0,
      currentIndex: 0, // 当前播放的视频索引
      videoList: [
      ],   // 视频列表数据
      videoContexts: [], // 视频上下文对象集合
      loading: false,  // 是否正在加载
      page: 1,         // 当前页码
      pageSize: 10     // 每页数量
    }
  },
  onShow() {
     this.loadVideos()
     // 如果视频按下暂停后切换页面再回到页面时,只算暂停时间(因为暂停时间和离开页面时间是重复的,只算一个)
     if(this.startHidenTime !== 0 && this.currentVideoIsPlaying) {
        const duration = Date.now() - this.startHidenTime
        this.totalHidenTime += duration
     }
  },
  onHide() {
     this.startHidenTime = Date.now()
  },
  onLoad() {
    // this.loadVideos();
     this.loadVideos();
  },
  onReady() {
    // 初始化视频上下文
    this.initVideoContexts();
  },
  methods: {
      // 格式化时间
       formatTime(time) {
         const date = new Date(time);
         const now = new Date();
         const diff = Math.floor((now - date) / 1000); // 秒
         if (diff < 60) return '刚刚';
         if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
         if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
         return `${date.getMonth() + 1}月${date.getDate()}日`;
       },
      // 提交评论
       async submitComment() {
         if (!this.commentForm.commentContent.trim()) {
           uni.showToast({
             title: '评论内容不能为空',
             icon: 'none'
           });
           return;
         }
        // 发表评论
         addVideoComment(this.commentForm).then(res => {
           if(res.data.code === 200) {
              this.commentForm = {
                         id: null,
                         videoId: null,
                         commentContent: '',
                         replyId: null
              }
              this.comments.unshift(res.data.data);
              console.log("新增后",this.comments);
              uni.showToast({
                title: '评论成功'
              });
              // 当前视频评论数加一
              this.commentsTotal += 1;
              this.videoList[this.currentIndex].commentNum += 1;
           } else {
              uni.showToast({
                      title: res.data.msg,
                      icon: 'none'
                    });
           }
        }).catch(() => {
           uni.showToast({
                   title: '评论失败',
                   icon: 'none'
                 });
        })
       },
       // 关闭评论弹窗
       closeCommentPopup() {
        this.$refs.commentPopup.close()
         this.showCommentPopup = false;
         this.comments = [];
         this.commentForm = {
           id: null,
           videoId: null,
           commentContent: '',
           replyId: null
        }
        this.commentQuery.pageNumber = 1;
        this.commentNoMore = false;
       },
      // 下滑评论区加载评论
      async getCommentPage() {
         if(this.commentNoMore) {
            return;
         }
         getVideoComments(this.commentQuery).then(res => {
            if(this.commentQuery.pageNumber === 1) {
               this.comments = res.data.data
            } else {
               this.comments = [
                 ...this.comments,
                 ...res.data.data.filter(
                   (newItem) => !this.comments.some((oldItem) => oldItem.id === newItem.id)
                 ),
               ];
            }
            if (res.data.data.length < this.commentQuery.pageSize) {
               this.commentNoMore = true;
               return;
            }
            this.commentQuery.pageNumber++;
         })
      },
       // 显示评论弹窗
       async showComments(item) {
         this.commentForm.videoId = item.id;
         this.$refs.commentPopup.open();
         this.commentLoading = true;
         this.commentQuery.videoId = item.id
        // 首次加载评论分页大小增加一倍,以产生滚动条,后续可触发
        this.commentQuery.pageSize *= 2;
        getVideoComments(this.commentQuery).then(res => {
           this.commentsTotal = res.data.total;
           this.comments = res.data.data;
           this.commentQuery.pageNumber += 2;
           this.commentQuery.pageSize /= 2;
        }).catch(() => {
           uni.showToast({
             title: '获取评论失败',
             icon: 'none'
           });
        }).finally(() => {
           this.commentLoading = false;
        })
       },
     // 关注作者
     subscribeAuth(index, authorId) {
      this.videoList.forEach(video => {
         if(video.authorId === authorId) {
            video.subscribeThisAuthor = true
         }
      })
      subscribe(authorId).then(res => {
         if(res.data.code === 200) {
            uni.showToast({
              title: '关注成功~',
              icon: 'none'
            });
         } else {
            this.videoList.forEach(video => {
               if(video.authorId === authorId) {
                  video.subscribeThisAuthor = false
               }
            })
         }
      })
     },
    // 初始化视频上下文
    initVideoContexts() {
      this.videoContexts = this.videoList.map((_, index) => {
        let videoContent = uni.createVideoContext(`video${index}`, this);
        // videoContent.requestFullScreen({ direction: 0 });
        return videoContent;
      });
    },
@@ -157,82 +346,147 @@
      if (this.loading) return;
      this.loading = true;
      
      try {
        const res = await uni.request({
          url: 'https://your-api.com/videos',
          data: {
            page: this.page,
            pageSize: this.pageSize
          }
        });
        if (this.page === 1) {
          this.videoList = res.data.list;
        } else {
          this.videoList = [...this.videoList, ...res.data.list];
        }
        this.page++;
        this.$nextTick(() => {
          this.initVideoContexts();
        });
      } catch (e) {
        console.error('加载视频失败', e);
      } finally {
        this.loading = false;
      }
     getRecommendVideos({pageNumber: this.page, pageSize: this.pageSize}).then(res => {
        console.log(res, "视频数据");
        if (this.page === 1) {
          this.videoList = res.data.data;
        } else {
          this.videoList = [...this.videoList, ...res.data.data];
        }
        this.page++;
        this.$nextTick(() => {
          this.initVideoContexts();
        });
        this.loading = false;
     })
    },
    
    // 滑动切换视频
    onSwiperChange(e) {
      const oldIndex = this.currentIndex;
      this.currentIndex = e.detail.current;
      // 暂停上一个视频
      if (this.videoContexts[oldIndex]) {
        this.videoContexts[oldIndex].pause();
      }
      // 播放当前视频
      if (this.videoContexts[this.currentIndex]) {
        this.videoContexts[this.currentIndex].play();
      }
      // 如果视频处于暂停状态往下刷视频,那么需要再计算一次暂停时间
      if(!this.currentVideoIsPlaying) {
         if(this.startPauseTime !== 0) {
            const duration = Date.now() - this.startPauseTime
            this.totalPauseTime += duration
         }
      }
      // 保存上一个视频的播放记录
      this.savePlayRecord()
      const oldIndex = this.currentIndex;
      this.currentIndex = e.detail.current;
      // 暂停上一个视频
      if (this.videoContexts[oldIndex]) {
         this.videoContexts[oldIndex].pause();
      }
      this.startPauseTime = 0;
      // 播放当前视频
      if (this.videoContexts[this.currentIndex]) {
         this.videoContexts[this.currentIndex].play();
      }
    },
    
    // 点赞/取消点赞
    toggleLike(item) {
      item.isLiked = !item.isLiked;
      item.likeCount += item.isLiked ? 1 : -1;
      uni.request({
        url: `https://your-api.com/video/${item.id}/like`,
        method: item.isLiked ? 'POST' : 'DELETE'
      });
    // 收藏/取消收藏
    toggleCollect(item, index) {
     let data = {
        refId: item.id,
        collectType: 'video'
     }
     const beforeCollected = item.collected
     const beforeCollectNum = item.collectNum
     if(item.collected) {
        this.videoList[index].collected = false
        this.videoList[index].collectNum -= 1
     } else {
        this.videoList[index].collected = true
        this.videoList[index].collectNum += 1
     }
      changeCollect(data).then(res => {
        if(res.data.code !== 200) {
           this.videoList[index].collected = beforeCollected
           this.videoList[index].collectNum = beforeCollectNum
        }
     })
    },
    // 单击屏幕:暂停或继续播放
   togglePlay(index) {
      if(this.currentVideoIsPlaying) {
         this.videoContexts[index].pause();
      } else {
         this.videoContexts[index].play();
      }
   },
    // 视频播放事件
    onPlay(index) {
      console.log(`视频 ${index} 开始播放`);
    onPlay(id, index) {
      console.log(id, index, "触发播放");
      if(index === this.currentIndex) {
         this.currentVideoIsPlaying = true;
      } else {
         this.currentVideoIsPlaying = false;
         return
      }
      this.playRecord.videoId = id;
      // 没初始化才赋值,因为一个视频重复播放onPlay会重复触发
      if(this.playRecord.startPlayTime === 0) {
         this.playRecord.startPlayTime = Date.now();
      }
      if(this.startPauseTime !== 0) {
         const duration = Date.now() - this.startPauseTime
         this.totalPauseTime += duration
      }
    },
    
    // 视频暂停事件
    onPause(index) {
      console.log(`视频 ${index} 暂停`);
      console.log(index, "触发暂停");
      if(index === this.currentIndex) {
         this.currentVideoIsPlaying = false;
      } else {
         this.currentVideoIsPlaying = true;
         return
      }
     this.startPauseTime = Date.now()
    },
    
    // 视频结束事件
    onEnded(index) {
      console.log(`视频 ${index} 播放结束`);
      // 自动播放下一个(如果不在最后一个)
      if (index < this.videoList.length - 1) {
        this.currentIndex = index + 1;
      }
    }
      // this.currentVideoIsPlaying = false;
    },
   // 记录播放时长
   onTimeUpdate(e) {
      this.playRecord.playAt = e.detail.currentTime
   },
   // 保存播放记录
   async savePlayRecord() {
      console.log(Date.now(), this.playRecord.startPlayTime, this.totalHidenTime);
      const data = {
         videoId: this.playRecord.videoId,
         viewDuration: Date.now() - this.playRecord.startPlayTime - this.totalHidenTime - this.totalPauseTime,
         playAt: this.playRecord.playAt
      }
      this.playRecord = {
         videoId: null,
         viewDuration: 0, // 这个视频总共观看了多久
         playAt: 0 ,// 这个视频播放到哪了
         startPlayTime: 0 // 这个视频从什么时候开始播放的
      }
      this.totalHidenTime = 0
      this.totalPauseTime = 0
      savePlayRecord(data)
   }
  }
}
</script>
<style scoped>
   ::v-deep .custom-tabbar {
      border-top: none !important;
   }
   .video-container {
     width: 100%;
     height: 100vh;
@@ -249,11 +503,21 @@
     height: 100%;
     object-fit: cover;
   }
   .play-icon {
     position: absolute;
     top: 50%;
     left: 50%;
     transform: translate(-50%, -50%);
     width: 45px;
     height: 45px;
     z-index: 10;
     opacity: 0.6;
   }
   
   .video-info {
     width: 70%;
     position: absolute;
     bottom: 50px;
     bottom: 70px;
     left: 20px;
     color: #f8f8f8;
     z-index: 10;
@@ -320,7 +584,7 @@
   /* 商品链接悬挂层样式 */
   .goods-link-warp {
      position: absolute;
      bottom: 100px;
      bottom: 160px;
      left: 20px;
      color: #f8f8f8;
      z-index: 10;
@@ -396,5 +660,110 @@
     font-size: 26rpx;
     font-weight: bold;
   }
   /* 评论弹窗样式 */
   .comment-popup {
     background-color: #fff;
     border-radius: 20rpx 20rpx 0 0;
     padding-bottom: env(safe-area-inset-bottom);
     height: 60vh;
     display: flex;
     flex-direction: column;
   }
   .popup-header {
     padding: 30rpx;
     display: flex;
     justify-content: space-between;
     align-items: center;
     border-bottom: 1rpx solid #f5f5f5;
   }
   .popup-title {
     font-size: 32rpx;
     font-weight: bold;
   }
   .close-icon {
     /* font-size: 36rpx; */
     color: #999;
   }
   .comment-list {
     flex: 1;
     padding: 0rpx 20rpx 20rpx 20rpx;
     box-sizing: border-box;
     height: calc(60vh - 260rpx);
   }
   .comment-item {
     display: flex;
     padding: 10rpx 0;
   }
   .avatar {
     width: 80rpx;
     height: 80rpx;
     border-radius: 50%;
     margin-right: 20rpx;
   }
   .comment-content {
     flex: 1;
   }
   .nickname {
     font-size: 24rpx;
     color: #666;
     display: block;
     margin-bottom: 10rpx;
   }
   .content {
     font-size: 24rpx;
     color: #333;
     display: block;
     margin-bottom: 10rpx;
   }
   .time {
     font-size: 24rpx;
     color: #999;
   }
   .comment-input-area {
     display: flex;
     padding: 20rpx 30rpx;
     align-items: center;
   }
   .comment-input {
     flex: 1;
     background-color: #fff;
     height: 80rpx;
     border: 1px solid #dcdcdc;
     border-radius: 40rpx;
     padding: 0 30rpx;
     font-size: 28rpx;
   }
   .placeholder {
     color: #ccc;
   }
   .submit-btn {
     margin-left: 20rpx;
     background-color: #07c160;
     color: #fff;
     border-radius: 40rpx;
     padding: 0 30rpx;
     height: 80rpx;
     line-height: 80rpx;
     font-size: 28rpx;
   }
   .loading, .empty {
     padding: 40rpx 0;
     text-align: center;
     color: #999;
   }
</style>