Forráskód Böngészése

使用 gemini 优化 VideoPage.vue 和相关 card 的 UI, 并拆分代码为相关的 card

reghao 19 órája
szülő
commit
184193bbb4

+ 175 - 86
src/components/card/SideVideoCard.vue

@@ -1,111 +1,200 @@
 <template>
-  <el-col style="padding-right: 7px; padding-left: 7px">
-    <div style="cursor: pointer" :title="video.title">
-      <el-card :body-style="{ padding: '0px' }" class="card">
-        <el-col :md="8" :sm="8" :xs="8">
-          <router-link target="_blank" :to="`/video/${video.videoId}`">
-            <div class="imgs">
-              <el-image
-                lazy
-                fit="cover"
-                :src="video.coverUrl"
-                class="coverImg"
-              />
-              <span style="position: absolute; bottom: 0; left: 0; color:white">
-                <i v-if="video.horizontal" class="el-icon-monitor" />
-                <i v-else class="el-icon-mobile-phone" />
-              </span>
-              <span style="position: absolute; bottom: 0; right: 0; color:white"> {{ video.duration }} </span>
-            </div>
-          </router-link>
-        </el-col>
-        <el-col :md="16" :sm="16" :xs="16">
-          <div style="padding: 4px">
-            <router-link style="text-decoration-line: none" target="_blank" :to="`/video/${video.videoId}`">
-              <span style="left: 0;margin-bottom: 0px;color: black;">{{ video.title | ellipsis }}</span>
-            </router-link>
-          </div>
-          <div style="padding: 4px">
-            <span style="left: 0;margin-bottom: 0px;color: black;">
-              <router-link style="text-decoration-line: none" target="_blank" :to="`/user/${video.user.userId}`">
-                <i class="el-icon-user"> {{ video.user.screenName | ellipsisUsername }} </i></router-link>
-            </span>
+  <div class="side-video-card" :title="video.title">
+    <router-link :to="`/video/${video.videoId}`" class="card-link">
+      <div class="thumb-wrapper">
+        <el-image
+            lazy
+            fit="cover"
+            :src="video.coverUrl"
+            class="cover-img"
+        >
+          <div slot="placeholder" class="image-slot">
+            <i class="el-icon-picture-outline"></i>
           </div>
-          <div style="padding: 4px">
-            <el-col :md="8" :sm="8" :xs="8">
-              <span class="el-icon-video-play" style="left: 0;margin-bottom: 0px;color: black;">
-                {{ video.view }}
-              </span>
-            </el-col>
-            <el-col :md="8" :sm="8" :xs="8">
-              <span class="el-icon-s-comment" style="left: 0;margin-bottom: 0px;color: black;">
-                {{ video.comment }}
-              </span>
-            </el-col>
+        </el-image>
+
+        <div class="badge-tag left-top">
+          <i v-if="video.horizontal" class="el-icon-monitor" />
+          <i v-else class="el-icon-mobile-phone" />
+        </div>
+
+        <div class="badge-tag right-bottom">
+          {{ video.duration }}
+        </div>
+      </div>
+
+      <div class="content-wrapper">
+        <h4 class="video-title">{{ video.title }}</h4>
+
+        <div class="video-info">
+          <router-link :to="`/user/${video.user.userId}`" class="up-name">
+            <i class="el-icon-user"></i>
+            <span>{{ video.user.screenName }}</span>
+          </router-link>
+
+          <div class="stats-row">
+            <span><i class="el-icon-video-play"></i> {{ video.view }}</span>
+            <span><i class="el-icon-chat-dot-round"></i> {{ video.comment }}</span>
           </div>
-        </el-col>
-      </el-card>
-    </div>
-  </el-col>
+        </div>
+      </div>
+    </router-link>
+  </div>
 </template>
 
 <script>
 export default {
   name: 'SideVideoCard',
-  filters: {
-    ellipsis(value) {
-      if (!value) return ''
-      const max = 20
-      if (value.length > max) {
-        return value.slice(0, max) + '...'
-      }
-      return value
-    },
-    ellipsisUsername(value) {
-      if (!value) return ''
-      const max = 10
-      if (value.length > max) {
-        return value.slice(0, max) + '...'
-      }
-      return value
-    }
-  },
   props: {
     video: {
       type: Object,
-      default: null
-    },
-    // 时间前的描述
-    dateTit: {
-      type: String,
-      default: ''
+      required: true
     }
-  },
-  methods: {
   }
 }
 </script>
 
-<style scoped>
-/*处于手机屏幕时*/
-@media screen and (max-width: 768px) {
-  .coverImg {
-    height: 80px !important;
+<style lang="scss" scoped>
+.side-video-card {
+  margin-bottom: 12px;
+  border-radius: 8px;
+  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+  overflow: hidden;
+
+  &:hover {
+    background-color: rgba(0, 0, 0, 0.04);
+    .video-title {
+      color: #409EFF;
+    }
+    .cover-img {
+      transform: scale(1.05);
+    }
   }
 }
 
-.coverImg {
-  width: 100%;
-  height: 120px;
-  display: block;
+.card-link {
+  display: flex;
+  text-decoration: none;
+  padding: 4px;
 }
 
-.card {
-  margin-bottom: 20px;
-  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+/* 左侧图片区域 */
+.thumb-wrapper {
+  position: relative;
+  width: 140px;
+  height: 80px;
+  flex-shrink: 0;
+  border-radius: 6px;
+  overflow: hidden;
+  background-color: #f0f2f5;
+
+  .cover-img {
+    width: 100%;
+    height: 100%;
+    transition: transform 0.5s;
+  }
+
+  .image-slot {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    color: #909399;
+    font-size: 20px;
+  }
 }
 
-.imgs {
-  position: relative;
+/* 浮层标签 */
+.badge-tag {
+  position: absolute;
+  padding: 2px 4px;
+  background: rgba(0, 0, 0, 0.6);
+  color: #fff;
+  font-size: 11px;
+  border-radius: 4px;
+  z-index: 1;
+
+  &.left-top {
+    top: 4px;
+    left: 4px;
+    background: rgba(0, 0, 0, 0.4);
+  }
+
+  &.right-bottom {
+    bottom: 4px;
+    right: 4px;
+  }
+}
+
+/* 右侧文字内容区域 */
+.content-wrapper {
+  flex: 1;
+  margin-left: 10px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  min-width: 0; // 保证 text-overflow 生效
+}
+
+.video-title {
+  margin: 0;
+  font-size: 14px;
+  font-weight: 500;
+  color: #1f2f3d;
+  line-height: 1.4;
+  display: -webkit-box;
+  -webkit-line-clamp: 2; // 两行省略
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  transition: color 0.2s;
+}
+
+.video-info {
+  .up-name {
+    display: flex;
+    align-items: center;
+    text-decoration: none;
+    color: #909399;
+    font-size: 12px;
+    margin-bottom: 4px;
+
+    &:hover {
+      color: #409EFF;
+    }
+
+    i {
+      margin-right: 4px;
+    }
+
+    span {
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+}
+
+.stats-row {
+  display: flex;
+  gap: 12px;
+  color: #99a2aa;
+  font-size: 12px;
+
+  i {
+    margin-right: 3px;
+  }
+}
+
+/* 移动端适配 */
+@media screen and (max-width: 768px) {
+  .thumb-wrapper {
+    width: 120px;
+    height: 70px;
+  }
+
+  .video-title {
+    font-size: 13px;
+    -webkit-line-clamp: 2;
+  }
 }
 </style>

+ 187 - 92
src/components/card/UserAvatarCard.vue

@@ -1,64 +1,65 @@
 <template>
-  <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-    <el-card class="box-card">
-      <div slot="header" class="clearfix">
-        <el-row>
-          <el-col :md="4">
-            <router-link target="_blank" :to="`/user/` + userAvatar.userId">
-              <el-avatar>
-                <el-image :src="userAvatar.avatarUrl" />
-              </el-avatar>
-            </router-link>
-          </el-col>
-          <el-col :md="16">
-            <el-row>
-              <span v-html="userAvatar.screenName" />
-            </el-row>
-            <el-row>
-              <span>关注 {{ userAvatar.following }}</span>
-              <span v-html="'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'" />
-              <span>粉丝 {{ userAvatar.follower }}</span>
-            </el-row>
-            <el-row v-if="userAvatar.signature !== null">
-              <span>{{ userAvatar.signature }}</span>
-            </el-row>
-          </el-col>
-        </el-row>
+  <el-card class="user-avatar-card" shadow="hover">
+    <div class="card-body">
+      <div class="user-info-section">
+        <router-link :to="`/user/` + userAvatar.userId" class="avatar-link">
+          <el-avatar :size="64" class="custom-avatar">
+            <el-image :src="userAvatar.avatarUrl" fit="cover" />
+          </el-avatar>
+        </router-link>
+
+        <div class="info-content">
+          <div class="name-row">
+            <span class="screen-name" v-html="userAvatar.screenName"></span>
+            <el-tag v-if="userAvatar.isVip" size="mini" type="warning" effect="dark" class="vip-tag">VIP</el-tag>
+          </div>
+          <div class="stats-row">
+            <span class="stat-item"><b>{{ userAvatar.following }}</b> 关注</span>
+            <span class="stat-divider">|</span>
+            <span class="stat-item"><b>{{ userAvatar.follower }}</b> 粉丝</span>
+          </div>
+        </div>
       </div>
-      <div class="text item">
-        <el-row>
-          <el-col :md="18">
-            <el-button
-              v-if="userAvatar.followed"
-              type="danger"
-              size="mini"
-              icon="el-icon-check"
-              @click="unfollowUser(userAvatar.userId)"
-            >
-              <span>已关注</span>
-            </el-button>
-            <el-button
-              v-else
-              type="danger"
-              size="mini"
-              icon="el-icon-plus"
-              @click="followUser(userAvatar.userId)"
-            >
-              <span>关注</span>
-            </el-button>
-            <el-button
-              type="danger"
-              size="mini"
-              icon="el-icon-message"
-              @click="sendMessage(userAvatar.userId)"
-            >
-              <span>发消息</span>
-            </el-button>
-          </el-col>
-        </el-row>
+
+      <div v-if="userAvatar.signature" class="signature-section">
+        <p class="signature-text">{{ userAvatar.signature | ellipsis }}</p>
       </div>
-    </el-card>
-  </el-row>
+
+      <div class="action-section">
+        <el-button
+            v-if="userAvatar.followed"
+            type="info"
+            size="medium"
+            round
+            class="follow-btn followed"
+            @click="unfollowUser(userAvatar.userId)"
+        >
+          <i class="el-icon-check"></i> <span>已关注</span>
+        </el-button>
+        <el-button
+            v-else
+            type="danger"
+            size="medium"
+            round
+            class="follow-btn"
+            @click="followUser(userAvatar.userId)"
+        >
+          <i class="el-icon-plus"></i> <span>关注</span>
+        </el-button>
+
+        <el-button
+            size="medium"
+            round
+            plain
+            class="msg-btn"
+            icon="el-icon-chat-dot-round"
+            @click="sendMessage(userAvatar.userId)"
+        >
+          发消息
+        </el-button>
+      </div>
+    </div>
+  </el-card>
 </template>
 
 <script>
@@ -68,69 +69,163 @@ export default {
   name: 'UserAvatarCard',
   filters: {
     ellipsis(value) {
-      if (!value) return ''
-      const max = 20
-      if (value.length > max) {
-        return value.slice(0, max) + '...'
-      }
-      return value
+      if (!value) return '该用户很懒,什么都没有留下'
+      const max = 35
+      return value.length > max ? value.slice(0, max) + '...' : value
     }
   },
   props: {
     userAvatar: {
       type: Object,
-      default: null
-    },
-    // 时间前的描述
-    dateTit: {
-      type: String,
-      default: ''
+      default: () => ({})
     }
   },
-  data() {
-    return {
-      followButton: {
-        icon: 'el-icon-plus',
-        text: '关注'
-      }
-    }
-  },
-  created() {
-  },
   methods: {
     followUser(userId) {
       followUser(userId).then(resp => {
         if (resp.code === 0) {
-          this.userAvatar.followed = true
+          this.$set(this.userAvatar, 'followed', true)
+          this.userAvatar.follower++
+          this.$message.success('关注成功')
         }
       })
     },
     unfollowUser(userId) {
       unfollowUser(userId).then(resp => {
         if (resp.code === 0) {
-          this.userAvatar.followed = false
+          this.$set(this.userAvatar, 'followed', false)
+          this.userAvatar.follower--
+          this.$message.info('取消关注')
         }
       })
     },
     sendMessage(userId) {
-      this.$message.info('暂未实现')
+      this.$message.info('私信功能开发中')
     }
   }
 }
 </script>
 
-<style scoped>
-/*处于手机屏幕时*/
-@media screen and (max-width: 768px) {
+<style scoped lang="scss">
+.user-avatar-card {
+  border-radius: 12px;
+  border: none;
+  background: #ffffff;
+  transition: all 0.3s ease;
+
+  .card-body {
+    padding: 10px 5px;
+  }
+}
+
+.user-info-section {
+  display: flex;
+  align-items: center;
+  gap: 15px;
+  margin-bottom: 15px;
+
+  .custom-avatar {
+    border: 2px solid #fff;
+    box-shadow: 0 4px 10px rgba(0,0,0,0.1);
+    background-color: #f5f7fa;
+  }
+
+  .info-content {
+    flex: 1;
+
+    .name-row {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      margin-bottom: 4px;
+
+      .screen-name {
+        font-size: 17px;
+        font-weight: 600;
+        color: #303133;
+      }
+    }
+
+    .stats-row {
+      font-size: 13px;
+      color: #909399;
+
+      .stat-divider {
+        margin: 0 8px;
+        color: #e4e7ed;
+      }
+
+      b {
+        color: #303133;
+      }
+    }
+  }
 }
 
-.clearfix:before,
-.clearfix:after {
-  display: table;
-  content: "";
+.signature-section {
+  margin-bottom: 18px;
+  padding: 8px 12px;
+  background: #f8f9fa;
+  border-radius: 8px;
+
+  .signature-text {
+    margin: 0;
+    font-size: 12px;
+    color: #606266;
+    line-height: 1.5;
+  }
 }
 
-.clearfix:after {
-  clear: both;
+.action-section {
+  display: flex;
+  gap: 10px;
+
+  .follow-btn {
+    flex: 1;
+    font-weight: 500;
+
+    &.followed {
+      background-color: #f4f4f5;
+      border-color: #e9e9eb;
+      color: #909399;
+
+      &:hover {
+        background-color: #e9e9eb;
+        color: #f56c6c; /* 悬停提示取消关注颜色 */
+      }
+    }
+  }
+
+  .msg-btn {
+    flex: 1;
+    color: #606266;
+    border-color: #dcdfe6;
+
+    &:hover {
+      color: #409eff;
+      border-color: #c6e2ff;
+      background-color: #ecf5ff;
+    }
+  }
+}
+
+/* 响应式适配 */
+@media screen and (max-width: 768px) {
+  .user-info-section {
+    gap: 10px;
+    .custom-avatar {
+      width: 50px !important;
+      height: 50px !important;
+    }
+    .screen-name {
+      font-size: 15px !important;
+    }
+  }
+  .action-section {
+    .el-button {
+      padding: 8px 15px;
+      font-size: 12px;
+    }
+  }
 }
 </style>

+ 125 - 0
src/components/card/UserCommentCard.vue

@@ -0,0 +1,125 @@
+<template>
+  <el-card class="comment-card shadow-box">
+    <div slot="header" class="comment-header">
+      <span>全部评论 <small>{{ totalSize }}</small></span>
+    </div>
+    <div class="comment-container">
+      <comment
+          v-model="dataList"
+          :user="currentUser"
+          :props="commentProps"
+          :before-submit="submit"
+          :before-like="like"
+          :before-delete="deleteComment"
+          :upload-img="uploadImg"
+      />
+      <div class="pagination-wrapper">
+        <el-pagination
+            :small="screenWidth <= 768"
+            hide-on-single-page
+            background
+            layout="prev, pager, next"
+            :page-size="pageSize"
+            :current-page="currentPage"
+            :total="totalSize"
+            @current-change="handleCurrentChange"
+        />
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script>
+import comment from '@/components/comment'
+import { publishComment, getComment } from '@/api/comment'
+
+export default {
+  name: 'UserCommentCard',
+  components: { comment },
+  props: {
+    videoId: { type: String, required: true },
+    currentUser: { type: Object, required: true },
+    screenWidth: { type: Number, default: 1200 }
+  },
+  data() {
+    return {
+      currentPage: 1,
+      pageSize: 20,
+      totalSize: 0,
+      dataList: [],
+      commentProps: {
+        id: 'commentId',
+        content: 'content',
+        imgSrc: 'imageUrl',
+        children: 'children',
+        likes: 'likes',
+        liked: 'liked',
+        reply: 'reply',
+        createAt: 'createAt',
+        total: 'total',
+        user: 'user'
+      }
+    }
+  },
+  watch: {
+    videoId: {
+      immediate: true,
+      handler(newVal) {
+        if (newVal) this.getCommentWrapper(1)
+      }
+    }
+  },
+  methods: {
+    handleCurrentChange(page) {
+      this.currentPage = page
+      this.getCommentWrapper(page)
+      window.scrollTo({ top: document.querySelector('.comment-card').offsetTop - 100, behavior: 'smooth' })
+    },
+    getCommentWrapper(pageNumber) {
+      getComment(this.videoId, pageNumber).then(resp => {
+        if (resp.code === 0) {
+          this.dataList = resp.data.list
+          this.totalSize = resp.data.totalSize
+        } else {
+          this.$message.error(resp.msg)
+        }
+      }).catch(err => this.$message.error(err.message))
+    },
+    async submit(newComment, parent, add) {
+      // 模拟延迟
+      await new Promise(resolve => setTimeout(resolve, 300))
+
+      const payload = { newComment, parent, postId: this.videoId }
+      publishComment(payload).then(resp => {
+        if (resp.code === 0) {
+          add(Object.assign(newComment, { postId: this.videoId }))
+          if (parent === null) this.totalSize += 1
+          this.$notify.success({ message: '评论已发布' })
+        } else {
+          this.$notify.warning({ message: '评论发布失败' })
+        }
+      })
+    },
+    async like(comment) {
+      console.log('Like comment:', comment)
+      // 这里可以调用点赞接口
+    },
+    async deleteComment(comment, parent) {
+      console.log('Delete comment:', comment)
+      // 这里可以调用删除接口
+    },
+    async uploadImg({ file, callback }) {
+      const reader = new FileReader()
+      reader.readAsDataURL(file)
+      reader.onload = () => callback(reader.result)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.comment-card { margin-bottom: 40px; }
+.comment-header { font-weight: 600; font-size: 18px; }
+.comment-header small { font-weight: normal; color: #909399; margin-left: 8px; }
+.pagination-wrapper { margin-top: 30px; display: flex; justify-content: center; }
+</style>

+ 133 - 0
src/components/card/VideoOpsCard.vue

@@ -0,0 +1,133 @@
+<template>
+  <div class="action-bar">
+    <div class="action-buttons">
+      <el-button-group class="mobile-group">
+        <el-button
+            type="primary"
+            plain
+            icon="el-icon-thumb"
+            @click="$emit('like', video)"
+        >
+          <span class="btn-text">喜欢</span> {{ video.thumbUp }}
+        </el-button>
+        <el-button
+            type="info"
+            plain
+            icon="el-icon-bottom"
+            @click="$emit('dislike', video)"
+        ></el-button>
+      </el-button-group>
+
+      <el-button
+          type="warning"
+          icon="el-icon-star-off"
+          class="ml-10"
+          @click="$emit('collect', video)"
+      >
+        <span class="btn-text">收藏</span> {{ video.favorite }}
+      </el-button>
+
+      <el-button
+          type="success"
+          icon="el-icon-share"
+          plain
+          @click="$emit('share')"
+      >
+        <span class="btn-text">分享</span>
+      </el-button>
+
+      <el-button
+          icon="el-icon-download"
+          plain
+          @click="$emit('download', video.videoId)"
+      >
+        <span class="btn-text">下载</span>
+      </el-button>
+
+      <el-dropdown trigger="click" class="ml-10">
+        <el-button icon="el-icon-more" circle size="small"></el-button>
+        <el-dropdown-menu slot="dropdown">
+          <el-dropdown-item v-if="showEdit" @click.native="$emit('edit')">
+            <i class="el-icon-edit"></i> 编辑
+          </el-dropdown-item>
+          <el-dropdown-item v-if="showEdit" @click.native="$emit('delete', video)" style="color: #F56C6C;">
+            <i class="el-icon-delete"></i> 删除
+          </el-dropdown-item>
+          <el-dropdown-item :divided="showEdit" @click.native="$emit('report')">
+            <i class="el-icon-warning-outline"></i> 报错
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </el-dropdown>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'VideoOpsCard',
+  props: {
+    video: {
+      type: Object,
+      required: true
+    },
+    showEdit: {
+      type: Boolean,
+      default: false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.action-bar {
+  padding-top: 15px;
+  border-top: 1px solid #333;
+}
+
+.action-buttons {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  overflow-x: auto;
+  scrollbar-width: none;
+}
+.action-buttons::-webkit-scrollbar { display: none; }
+
+.ml-10 { margin-left: 10px; }
+
+@media screen and (max-width: 768px) {
+  .action-bar {
+    padding: 10px 0;
+    border-top: 1px solid #eee;
+  }
+  .action-buttons {
+    justify-content: space-around;
+    width: 100%;
+  }
+  .el-button.ml-10 {
+    margin-left: 5px !important;
+  }
+  .el-button {
+    padding: 8px 10px !important;
+    font-size: 12px !important;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    border: none !important;
+    background: transparent !important;
+    color: #606266 !important;
+  }
+  .action-buttons i {
+    font-size: 20px !important;
+    margin-bottom: 4px;
+    margin-right: 0 !important;
+  }
+  .btn-text {
+    font-size: 11px;
+    color: #909399;
+  }
+  .el-button [class*="el-icon-"] + span {
+    margin-left: 0 !important;
+  }
+}
+</style>

+ 176 - 0
src/components/card/VideoPlayerCard.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="video-player-wrapper">
+    <div id="dplayer" ref="dplayerContainer"></div>
+  </div>
+</template>
+
+<script>
+import flvjs from 'flv.js'
+import DPlayer from 'dplayer'
+import { videoUrl } from '@/api/video'
+
+export default {
+  name: 'VideoPlayerCard',
+  props: {
+    videoId: { type: String, required: true },
+    videoData: { type: Object, required: true },
+    userToken: { type: String, default: null },
+    sendEvent: { type: Boolean, default: true }
+  },
+  data() {
+    return {
+      player: null,
+      wsClient: null,
+      wsUrl: null,
+      wsReconnectLock: false,
+      intervalEvent: null,
+      danmakuApi: process.env.VUE_APP_SERVER_URL + '/api/comment/danmaku/'
+    }
+  },
+  watch: {
+    videoId: {
+      immediate: true,
+      handler(val) {
+        if (val) this.loadVideoSource()
+      }
+    }
+  },
+  beforeDestroy() {
+    this.destroyResources()
+  },
+  methods: {
+    destroyResources() {
+      if (this.player) this.player.destroy()
+      if (this.wsClient) this.wsClient.close()
+      if (this.intervalEvent) clearInterval(this.intervalEvent)
+    },
+    loadVideoSource() {
+      this.destroyResources()
+      videoUrl(this.videoId).then(res => {
+        if (res.code === 0) {
+          // 初始化 WebSocket
+          if (this.userToken && this.sendEvent) {
+            this.wsUrl = this.getWsUrl(this.videoId)
+            this.initWebSocket()
+          }
+          // 初始化播放器
+          const { type, urls, currentTime } = res.data
+          if (type === 'mp4') {
+            urls.forEach(u => u.type = 'normal')
+            this.initMp4Player(urls, currentTime)
+          } else if (type === 'flv') {
+            this.initFlvPlayer(urls, currentTime)
+          }
+        }
+      })
+    },
+    getWsUrl(videoId) {
+      const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'
+      const host = window.location.host
+      return `${protocol}${host}/ws/media?userToken=${this.userToken}&videoId=${videoId}`
+    },
+    initMp4Player(urls, pos) {
+      this.player = new DPlayer({
+        container: this.$refs.dplayerContainer,
+        lang: 'zh-cn',
+        screenshot: true,
+        volume: 0.1,
+        video: { pic: this.videoData.coverUrl, defaultQuality: 0, quality: urls },
+        danmaku: { id: this.videoId, api: this.danmakuApi, token: this.userToken, bottom: '15%', unlimited: true }
+      })
+      this.player.seek(pos)
+      this.bindEvents()
+    },
+    initFlvPlayer(urls, pos) {
+      this.player = new DPlayer({
+        container: this.$refs.dplayerContainer,
+        lang: 'zh-cn',
+        screenshot: true,
+        volume: 0.1,
+        video: {
+          pic: this.videoData.coverUrl,
+          defaultQuality: 0,
+          quality: urls,
+          type: 'customFlv',
+          customType: {
+            customFlv: (video) => {
+              const flvPlayer = flvjs.createPlayer({ type: 'flv', url: video.src })
+              flvPlayer.attachMediaElement(video)
+              flvPlayer.load()
+            }
+          }
+        }
+      })
+      this.player.seek(pos)
+      this.bindEvents()
+    },
+    bindEvents() {
+      let ended = false
+      this.player.on('play', () => {
+        if (this.sendEvent) {
+          clearInterval(this.intervalEvent)
+          this.intervalEvent = setInterval(() => {
+            if (!ended) this.sendProgress(false)
+          }, 5000)
+        }
+      })
+      this.player.on('ended', () => {
+        ended = true
+        clearInterval(this.intervalEvent)
+        this.sendProgress(true)
+      })
+    },
+    sendProgress(isEnded) {
+      const jsonData = {
+        type: 'progress',
+        direction: 'c2s',
+        data: {
+          mediaId: this.videoId,
+          mediaType: 1,
+          currentTime: this.player.video.currentTime,
+          ended: isEnded
+        }
+      }
+      if (this.wsClient && this.wsClient.readyState === WebSocket.OPEN) {
+        this.wsClient.send(JSON.stringify(jsonData))
+      }
+    },
+    initWebSocket() {
+      this.wsClient = new WebSocket(this.wsUrl)
+      this.wsClient.onopen = () => this.$emit('ws-status', true)
+      this.wsClient.onclose = () => {
+        this.$emit('ws-status', false)
+        this.reconnect()
+      }
+      this.wsClient.onerror = () => this.reconnect()
+      this.wsClient.onmessage = (evt) => {
+        const msg = JSON.parse(evt.data)
+        this.$emit('update-view-count', msg.viewCount)
+      }
+    },
+    reconnect() {
+      if (this.wsReconnectLock) return
+      this.wsReconnectLock = true
+      setTimeout(() => {
+        this.initWebSocket()
+        this.wsReconnectLock = false
+      }, 5000)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.video-player-wrapper {
+  background: #000;
+  border-radius: 4px;
+  overflow: hidden;
+  width: 100%;
+}
+#dplayer {
+  height: 520px;
+}
+@media screen and (max-width: 768px) {
+  #dplayer { height: 240px; }
+}
+</style>

+ 187 - 1032
src/views/home/VideoPage.vue

@@ -1,1148 +1,303 @@
 <template>
-  <el-row v-if="!permissionDenied" class="movie-list">
-    <el-row v-if="video !== null">
-      <el-col :md="15">
-        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-          <el-card class="box-card">
-            <div slot="header" class="clearfix">
-              <el-row>
-                <h3 v-html="video.title" />
-              </el-row>
-              <el-row style="color: #999;font-size: 16px;padding-top: 0px;">
-                <span><i class="el-icon-video-play">{{ video.view }}</i></span>
-                <span v-html="'&nbsp;&nbsp;&nbsp;&nbsp;'" />
-                <span><i class="el-icon-s-comment">{{ video.comment }}</i></span>
-                <span v-html="'&nbsp;&nbsp;&nbsp;&nbsp;'" />
-                <span><i class="el-icon-watch">{{ video.pubDate }}</i></span>
-                <span v-html="'&nbsp;&nbsp;&nbsp;&nbsp;'" />
-                <span v-if="videoId !== null && videoId.includes('BV')">
-                  <i class="el-icon-apple"><a target="_blank" :href="`https://bilibili.com/` + videoId">bili</a></i>
-                </span>
-                <span v-html="'&nbsp;&nbsp;&nbsp;&nbsp;'" />
-                <span><i class="el-icon-view">{{ realtimeViewCount }} 正在观看</i></span>
-              </el-row>
-            </div>
-            <div class="text item">
-              <div id="dplayer" ref="dplayer" style="height: 480px;" />
-            </div>
-          </el-card>
-        </el-row>
-        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-          <el-card class="box-card">
-            <div slot="header" class="clearfix">
-              <div class="tag">
-                <el-button
-                  type="success"
-                  size="mini"
-                  icon="el-icon-plus"
-                  :disabled="isCollected"
-                  class="tag"
-                  @click="collection(video.videoId)"
-                >
-                  <span>收藏 {{ video.favorite }}</span>
-                </el-button>
-                <el-button
-                  type="success"
-                  size="mini"
-                  icon="el-icon-thumb"
-                  :disabled="isCollected"
-                  class="tag"
-                  @click="likeVideo(video)"
-                >
-                  <span>喜欢 {{ video.thumbUp }}</span>
-                </el-button>
-                <el-button
-                  type="success"
-                  size="mini"
-                  icon="el-icon-thumb"
-                  class="tag"
-                  @click="dislikeVideoWrapper(video)"
-                >
-                  <span>不喜欢 {{ video.thumbUp }}</span>
-                </el-button>
-                <el-button
-                  type="success"
-                  size="mini"
-                  icon="el-icon-share"
-                  :disabled="isCollected"
-                  class="tag"
-                  @click="displayShareVideoDialog"
-                >
-                  <span>分享 {{ video.share }}</span>
-                </el-button>
-                <el-button
-                  type="success"
-                  size="mini"
-                  icon="el-icon-download"
-                  class="tag"
-                  @click="getDownloadUrl(video.videoId)"
-                >
-                  <span>下载</span>
-                </el-button>
-                <el-button
-                  type="warning"
-                  size="mini"
-                  icon="el-icon-help"
-                  class="tag"
-                  @click="displayErrorReportDialog"
-                >
-                  <span>报错</span>
-                </el-button>
-                <div v-if="showEdit">
-                  <el-button
-                    type="danger"
-                    size="mini"
-                    icon="el-icon-delete"
-                    class="tag"
-                    @click="deleteVideo(video)"
-                  >
-                    <span>删除</span>
-                  </el-button>
-                  <el-button
-                    type="danger"
-                    size="mini"
-                    icon="el-icon-edit"
-                    class="tag"
-                    @click="displayEditDialog"
-                  >
-                    <span>编辑</span>
-                  </el-button>
-                </div>
+  <el-row v-if="!permissionDenied" class="video-page-container">
+    <el-row v-if="video !== null" :gutter="20">
+      <el-col :md="16" :lg="17">
+        <div class="player-section shadow-box">
+          <div class="video-header">
+            <h1 class="video-title" v-html="video.title" />
+            <div class="video-meta">
+              <div class="meta-left">
+                <span class="meta-item"><i class="el-icon-video-play"></i> {{ video.view }} 次播放</span>
+                <span class="meta-item"><i class="el-icon-s-comment"></i> {{ video.comment }} 评论</span>
+                <span class="meta-item"><i class="el-icon-watch"></i> {{ video.pubDate }}</span>
               </div>
-            </div>
-            <div class="text item">
-              <!--视频描述行-->
-              <div>
-                <span v-html="video.description" />
-              </div>
-              <el-divider />
-              <!--视频标签行-->
-              <div>
-                <el-tag
-                  v-for="(tag,index) in video.tags"
-                  :key="index"
-                  class="tag"
-                  size="medium"
-                  effect="plain"
-                >
-                  <router-link style="text-decoration-line: none" target="_blank" :to="`/video/tag/` + tag">
-                    {{ tag }}
-                  </router-link>
+              <div class="meta-right">
+                <el-tag v-if="videoId && videoId.includes('BV')" size="mini" type="info" effect="plain">
+                  <a target="_blank" :href="'https://bilibili.com/' + videoId" class="bili-link">B站源</a>
+                </el-tag>
+                <el-tag type="danger" size="small" effect="dark" class="live-tag">
+                  <i class="el-icon-view"></i> {{ realtimeViewCount }} 人正在看
                 </el-tag>
               </div>
             </div>
-          </el-card>
-        </el-row>
-        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-          <el-card class="box-card">
-            <div slot="header" class="clearfix">
-              <el-row>
-                <h3>视频评论</h3>
-              </el-row>
-            </div>
-            <div class="text item">
-              <div ref="comment" :style="wrapStyle" class="comment-wrap">
-                <comment
-                  v-model="dataList"
-                  :user="currentUser"
-                  :props="props"
-                  :before-submit="submit"
-                  :before-like="like"
-                  :before-delete="deleteComment"
-                  :upload-img="uploadImg"
-                />
-                <el-pagination
-                  :small="screenWidth <= 768"
-                  hide-on-single-page
-                  layout="prev, pager, next"
-                  :page-size="pageSize"
-                  :current-page="currentPage"
-                  :total="totalSize"
-                  @current-change="handleCurrentChange"
-                />
+          </div>
+
+          <div class="video-content">
+            <video-player-card
+                v-if="video"
+                :video-id="videoId"
+                :video-data="video"
+                :user-token="userToken"
+                :send-event="sendEvent"
+                @update-view-count="val => realtimeViewCount = val"
+            />
+          </div>
+
+          <video-ops-card
+              v-if="video"
+              :video="video"
+              :show-edit="showEdit"
+              @like="likeVideo"
+              @dislike="dislikeVideoWrapper"
+              @collect="collection"
+              @share="displayShareVideoDialog"
+              @download="getDownloadUrl"
+              @report="displayErrorReportDialog"
+              @edit="displayEditDialog"
+              @delete="deleteVideo"
+          />
+        </div>
+
+        <el-card class="info-card shadow-box">
+          <div class="video-description" v-html="video.description"></div>
+          <div class="video-tags">
+            <el-tag v-for="(tag,index) in video.tags" :key="index" class="video-tag-item" size="small" effect="light" round>
+              <router-link :to="'/video/tag/' + tag"># {{ tag }}</router-link>
+            </el-tag>
+          </div>
+        </el-card>
+
+        <user-comment-card v-if="videoId" :video-id="videoId" :current-user="currentUser" :screen-width="screenWidth" />
+      </el-col>
+
+      <el-col :md="8" :lg="7">
+        <div class="side-container">
+          <user-avatar-card v-if="user" :user-avatar="user" class="shadow-box mb-20" />
+          <el-card class="recommend-card shadow-box">
+            <div slot="header" class="recommend-header">
+              <span class="title">接下来播放</span>
+              <div class="auto-play">
+                <span>自动播放</span>
+                <el-switch v-model="autoPlay" size="mini" />
               </div>
             </div>
-          </el-card>
-        </el-row>
-      </el-col>
-      <el-col :md="9">
-        <el-row>
-          <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-            <user-avatar-card v-if="user !== null" :user-avatar="user" />
-          </el-row>
-        </el-row>
-        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-          <el-card class="box-card">
-            <div slot="header" class="clearfix">
-              <el-row>
-                <h3>接下来播放</h3>
-              </el-row>
-              <el-row>
-                <span>自动播放 <el-switch v-model="autoPlay" /></span>
-              </el-row>
+            <div class="recommend-list">
+              <div v-for="(item,index) in similarVideos" :key="index" class="side-card-wrapper">
+                <side-video-card :video="item" />
+              </div>
             </div>
           </el-card>
-        </el-row>
-        <el-row v-for="(item,index) in similarVideos" :key="index" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-          <side-video-card :video="item" />
-        </el-row>
+        </div>
       </el-col>
 
-      <!-- 添加到播放列表对话框 -->
-      <el-dialog
-        append-to-body
-        :visible.sync="showPlaylistDialog"
-        center
-      >
-        <el-card class="box-card">
-          <div slot="header" class="clearfix">
-            <span>添加到收藏夹</span>
-            <el-button style="float: right; padding: 3px 0" type="text" @click="addToPlaylist">确定</el-button>
-          </div>
-          <div class="text item">
-            <el-table
-              ref="singleTable"
-              :data="playlist"
-              highlight-current-row
-              style="width: 100%"
-              @current-change="handleCurrentRow"
-            >
-              <el-table-column
-                type="index"
-              />
-              <el-table-column
-                prop="albumName"
-              />
-              <el-table-column
-                prop="total"
-              />
-              <el-table-column
-                prop="total"
-              />
-            </el-table>
-            <br>
-            <div style="margin-top: 15px;">
-              <el-input
-                v-model="albumForm.albumName"
-                placeholder="请输入标题(最多可输入20个字)"
-                maxlength="20"
-                clearable
-              >
-                <el-button slot="append" icon="el-icon-plus" @click="onCreateAlbum">新建收藏夹</el-button>
-              </el-input>
-            </div>
-          </div>
-        </el-card>
+      <el-dialog append-to-body :visible.sync="showPlaylistDialog" title="保存到收藏夹" width="400px" center>
+        <el-table :data="playlist" highlight-current-row @current-change="handleCurrentRow">
+          <el-table-column type="index" width="50" />
+          <el-table-column prop="albumName" label="收藏夹名称" />
+          <el-table-column prop="total" label="内容数" width="80" />
+        </el-table>
+        <div class="new-album-input">
+          <el-input v-model="albumForm.albumName" placeholder="新建收藏夹..." size="medium">
+            <el-button slot="append" icon="el-icon-plus" @click="onCreateAlbum">创建</el-button>
+          </el-input>
+        </div>
+        <span slot="footer">
+          <el-button @click="showPlaylistDialog = false">取消</el-button>
+          <el-button type="primary" @click="addToPlaylist">确定收藏</el-button>
+        </span>
       </el-dialog>
-      <!-- 视频报错对话框 -->
-      <el-dialog
-        append-to-body
-        :visible.sync="showErrorReportDialog"
-        center
-      >
-        <el-card class="box-card">
-          <div slot="header" class="clearfix">
-            <span>视频报错</span>
-            <el-button style="float: right; padding: 3px 0" type="text" @click="submitErrorReport">提交错误</el-button>
-          </div>
-          <div class="text item">
-            <el-form ref="form" :model="errorReportForm" label-width="80px">
-              <el-form-item label="错误类型">
-                <el-select v-model="errorReportForm.errorCode" placeholder="选择视频错误类型">
-                  <el-option label="视频无封面" value="1" />
-                  <el-option label="视频无声音" value="2" />
-                  <el-option label="视频无画面" value="3" />
-                  <el-option label="视频无资源" value="4" />
-                  <el-option label="视频有广告" value="5" />
-                </el-select>
-              </el-form-item>
-            </el-form>
-          </div>
-        </el-card>
+
+      <el-dialog append-to-body :visible.sync="showErrorReportDialog" title="视频报错" width="400px">
+        <el-form :model="errorReportForm" label-position="top">
+          <el-form-item label="请选择错误类型">
+            <el-select v-model="errorReportForm.errorCode" placeholder="请选择" style="width: 100%">
+              <el-option label="视频无封面" value="1" /><el-option label="视频无声音" value="2" />
+              <el-option label="视频无画面" value="3" /><el-option label="视频无资源" value="4" />
+              <el-option label="视频有广告" value="5" />
+            </el-select>
+          </el-form-item>
+        </el-form>
+        <span slot="footer">
+          <el-button @click="showErrorReportDialog = false">取消</el-button>
+          <el-button type="primary" @click="submitErrorReport">提交反馈</el-button>
+        </span>
       </el-dialog>
-      <!-- 视频分享对话框 -->
-      <el-dialog
-        append-to-body
-        :visible.sync="showShareVideoDialog"
-        center
-      >
-        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px; padding-top: 5px">
-          <el-col :md="12">
-            <el-row>
-              <div class="imgs">
-                <el-image
-                  lazy
-                  fit="cover"
-                  class="coverImg"
-                  :src="video.coverUrl"
-                />
-              </div>
-            </el-row>
-            <el-row>
-              <el-button style="float: right; padding: 3px 0" type="text" @click="submitShareVideo">获取视频分享链接</el-button>
-            </el-row>
-          </el-col>
-          <el-col :md="12" />
-        </el-row>
-        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px; padding-top: 5px" />
+
+      <el-dialog append-to-body :visible.sync="showShareVideoDialog" title="分享视频" width="500px">
+        <div class="share-preview">
+          <el-image lazy fit="cover" class="share-cover" :src="video.coverUrl" />
+          <h4 v-html="video.title"></h4>
+        </div>
+        <span slot="footer">
+          <el-button type="primary" icon="el-icon-document-copy" @click="submitShareVideo">复制分享链接</el-button>
+        </span>
       </el-dialog>
-      <!-- 视频编辑对话框 -->
+
       <edit-video-card :video="video" :create-dialog="showEditDialog" @closeDialog="closeHandle" />
     </el-row>
   </el-row>
-  <el-row v-else>
+  <el-row v-else class="denied-container">
     <permission-denied-card :text-object="textObject" />
   </el-row>
 </template>
 
 <script>
-import PermissionDeniedCard from '@/components/card/PermissionDeniedCard'
-import SideVideoCard from 'components/card/SideVideoCard'
-import UserAvatarCard from '@/components/card/UserAvatarCard'
-import EditVideoCard from '@/components/card/EditVideoCard'
-import comment from '@/components/comment'
-
-import { similarVideo, videoInfo, downloadVideo, getShortUrl, videoUrl } from '@/api/video'
+import { similarVideo, videoInfo, downloadVideo, getShortUrl } from '@/api/video'
 import { videoErrorReport, videoErrorDelete } from '@/api/video_edit'
 import { collectItem, createAlbum, getUserAlbumList } from '@/api/collect'
 import { getUserInfo } from '@/api/user'
-import { publishComment, getComment } from '@/api/comment'
 import { getAccessToken, getAuthedUser } from '@/utils/auth'
 import { dislikeVideo } from '@/api/content'
-import flvjs from 'flv.js'
-import DPlayer from 'dplayer'
+
+// 组件导入
+import PermissionDeniedCard from '@/components/card/PermissionDeniedCard'
+import VideoPlayerCard from 'components/card/VideoPlayerCard'
+import SideVideoCard from 'components/card/SideVideoCard'
+import UserAvatarCard from '@/components/card/UserAvatarCard'
+import EditVideoCard from '@/components/card/EditVideoCard'
+import UserCommentCard from '@/components/card/UserCommentCard'
+import VideoOpsCard from 'components/card/VideoOpsCard'
 
 export default {
   name: 'VideoPage',
-  components: { EditVideoCard, SideVideoCard, UserAvatarCard, PermissionDeniedCard, comment },
+  components: { EditVideoCard, SideVideoCard, UserAvatarCard, PermissionDeniedCard, VideoPlayerCard, UserCommentCard, VideoOpsCard },
   data() {
     return {
-      // 屏幕宽度, 为了控制分页条的大小
       screenWidth: document.body.clientWidth,
-      currentPage: 1,
-      pageSize: 20,
-      totalSize: 0,
-      dataList: [],
-      // ********************************************************************/
-      wrapStyle: '',
-      videoComments: [
-        {
-          commentId: 114511,
-          content: 'this is comment content',
-          imageUrl: '',
-          children: [],
-          likes: 0,
-          liked: false,
-          reply: null,
-          createAt: 1700271326393,
-          user: {
-            userId: 1,
-            name: '西瓜',
-            avatar: ''
-          }
-        }
-      ],
-      currentUser: {
-        userId: -1,
-        name: '芒果',
-        avatar: '//picx.zhimg.com/v2-a2c89378a6332cbfed3e28b5ab84feb7.jpg',
-        author: true
-      },
-      // 自定义组件中 comment 对象的字段名
-      props: {
-        id: 'commentId',
-        content: 'content',
-        imgSrc: 'imageUrl',
-        children: 'children',
-        likes: 'likes',
-        liked: 'liked',
-        reply: 'reply',
-        createAt: 'createAt',
-        total: 'total',
-        user: 'user'
-      },
-      // ********************************************************************/
       videoId: null,
       video: null,
       user: null,
       similarVideos: [],
+      currentUser: { userId: -1, name: '游客', avatar: '', author: false },
       isCollected: false,
       showPlaylistDialog: false,
       playlist: [],
       showErrorReportDialog: false,
-      errorReportForm: {
-        videoId: null,
-        errorCode: null
-      },
+      errorReportForm: { videoId: null, errorCode: null },
       permissionDenied: false,
-      textObject: {
-        content: '视频',
-        route: '/video'
-      },
+      textObject: { content: '视频', route: '/video' },
       autoPlay: false,
-      albumForm: {
-        albumName: null
-      },
+      albumForm: { albumName: null },
       currentRow: null,
       showShareVideoDialog: false,
       showEditDialog: false,
       showEdit: true,
-      // ****************************************************************************************************************
-      flvjs,
-      DPlayer,
-      danmaku: {
-        api: process.env.VUE_APP_SERVER_URL + '/api/comment/danmaku/'
-      },
       userToken: null,
       sendEvent: true,
-      intervalEvent: null,
-      // ****************************************************************************************************************
-      wsUrl: null,
-      realtimeViewCount: 0,
-      wsClient: null,
-      wsConnectStatus: false,
-      wsReconnectLock: false
+      realtimeViewCount: 0
     }
   },
   watch: {
-    // 地址栏 url 发生变化时重新加载本页面
-    $route() {
-      this.$router.go()
-    }
+    '$route'() { this.$router.go() }
   },
   created() {
     this.userToken = getAccessToken()
-    this.danmaku.token = this.userToken
-
     const loginUser = getAuthedUser()
-    if (loginUser != null) {
-      this.currentUser = {
-        userId: loginUser.userId,
-        name: loginUser.screenName,
-        avatar: loginUser.avatarUrl,
-        author: true
-      }
+    if (loginUser) {
+      this.currentUser = { userId: loginUser.userId, name: loginUser.screenName, avatar: loginUser.avatarUrl, author: true }
     }
-
     this.videoId = this.$route.params.id
     this.getVideoInfo(this.videoId)
     this.getSimilarVideos(this.videoId)
-    this.getCommentWrapper(this.currentPage)
-  },
-  mounted() {
-    const header = this.$refs.header
-    if (header !== undefined && header !== null) {
-      this.wrapStyle = `height: calc(100vh - ${header.clientHeight + 20}px)`
-    }
   },
   methods: {
-    handleCurrentRow(val) {
-      this.currentRow = val
-      this.$message.info('选中 ' + this.currentRow.albumName)
-    },
-    // 用户点击收藏
+    handleCurrentRow(val) { this.currentRow = val },
     collection(video) {
-      if (video.collected) {
-        this.$message.info('取消收藏')
-        return
-      }
-
+      if (video.collected) return this.$message.info('取消收藏')
       this.showPlaylistDialog = true
-      getUserAlbumList(1).then(resp => {
-        if (resp.code === 0) {
-          this.playlist = resp.data.list
-        }
-      })
+      getUserAlbumList(1).then(resp => { if (resp.code === 0) this.playlist = resp.data.list })
     },
     addToPlaylist() {
-      if (this.currentRow === null) {
-        this.$message.info('请选择收藏夹')
-        return
-      }
-      const jsonData = {}
-      jsonData.albumId = this.currentRow.albumId
-      jsonData.postId = this.videoId
-      jsonData.action = 1
-
-      collectItem(jsonData).then(resp => {
-        if (resp.code === 0) {
-          this.$notify.success({
-            title: '视频已收藏',
-            type: 'success',
-            duration: 3000
-          })
-        } else {
-          this.$notify.warning({
-            title: '视频收藏失败',
-            type: 'warning',
-            duration: 3000
-          })
-        }
-      }).catch(error => {
-        this.$message.error(error)
-      }).finally(() => {
+      if (!this.currentRow) return this.$message.info('请选择收藏夹')
+      collectItem({ albumId: this.currentRow.albumId, postId: this.videoId, action: 1 }).then(resp => {
+        if (resp.code === 0) this.$notify.success({ title: '视频已收藏' })
         this.showPlaylistDialog = false
       })
     },
-    // ****************************************************************************************************************
-    handleCurrentChange(currentPage) {
-      this.currentPage = currentPage
-      this.getCommentWrapper(currentPage)
-      // 回到顶部
-      scrollTo(0, 0)
-    },
-    getCommentWrapper(pageNumber) {
-      getComment(this.videoId, pageNumber).then(resp => {
-        if (resp.code === 0) {
-          const respData = resp.data
-          this.dataList = respData.list
-          this.totalSize = respData.totalSize
-        } else {
-          this.$notify({
-            title: '提示',
-            message: resp.msg,
-            type: 'error',
-            duration: 3000
-          })
-        }
-      }).catch(error => {
-        this.$notify({
-          title: '提示',
-          message: error.message,
-          type: 'warning',
-          duration: 3000
-        })
-      })
-    },
-    // ****************************************************************************************************************
-    // 获取视频的详细信息
     getVideoInfo(videoId) {
       videoInfo(videoId).then(resp => {
         if (resp.code === 0) {
           this.video = resp.data
           document.title = resp.data.title
-          this.userId = resp.data.userId
-          getUserInfo(this.userId).then(resp => {
-            if (resp.code === 0) {
-              this.user = resp.data
-            } else {
-              this.$notify.error({
-                message: '用户数据获取失败',
-                type: 'warning',
-                duration: 3000
-              })
-            }
-          })
-          this.getVideoUrl(videoId)
+          this.loadUserInfo(resp.data.userId)
         } else if (resp.code === 2) {
           this.$router.push('/404')
         } else {
           this.permissionDenied = true
         }
-      }).catch(error => {
-        this.$notify.error({
-          message: error.message,
-          type: 'warning',
-          duration: 3000
-        })
       })
     },
-    // 获取和当前视频类似的其他视频
-    getSimilarVideos(videoId) {
-      similarVideo(videoId).then(resp => {
-        if (resp.code === 0) {
-          this.similarVideos = resp.data
-        } else {
-          this.$notify.error({
-            message: '推荐视频数据获取失败',
-            type: 'warning',
-            duration: 3000
-          })
-        }
-      }).catch(error => {
-        this.$notify.error({
-          message: error.message,
-          type: 'warning',
-          duration: 3000
-        })
-      })
+    loadUserInfo(userId) {
+      getUserInfo(userId).then(resp => { if (resp.code === 0) this.user = resp.data })
     },
-    // 换一换
-    refreshSimilar() {
-      console.log('刷新相关推荐')
-    },
-    likeVideo(video) {
-      const videoId = video.videoId
-      this.$message.info('喜欢 ' + videoId)
+    getSimilarVideos(videoId) {
+      similarVideo(videoId).then(resp => { if (resp.code === 0) this.similarVideos = resp.data })
     },
+    likeVideo(video) { this.$message.info('喜欢 ' + video.videoId) },
     dislikeVideoWrapper(video) {
-      const payload = {
-        videoId: video.videoId
-      }
-      dislikeVideo(payload).then(resp => {
-        if (resp.code === 0) {
-          this.$message.info('数据已提交')
-        }
-      })
+      dislikeVideo({ videoId: video.videoId }).then(resp => { if (resp.code === 0) this.$message.info('数据已提交') })
     },
     getDownloadUrl(videoId) {
-      // let filename
       downloadVideo(videoId).then(resp => {
-        if (resp.code === 0) {
-          const downloadUrl = resp.data.url
-          window.open(downloadUrl, '_blank')
-          /* fetch(downloadUrl.url, {
-            headers: {
-              Authorization: 'Bearer ' + downloadUrl.token
-            },
-            method: 'GET',
-            credentials: 'include'
-          }).then(resp => {
-            /!*
-            遍历 formdata
-            for (const key of resp.headers.keys()) {
-              console.log(key + ' : ' + resp.headers.get(key))
-            }*!/
-            const header = resp.headers.get('Content-Disposition')
-            const parts = header.split(';')
-            const encodeFilename = parts[1].split('=')[1]
-            filename = decodeURI(encodeFilename)
-            return resp.blob()
-          }).then(data => {
-            const blobUrl = window.URL.createObjectURL(data)
-            const a = document.createElement('a')
-            a.download = filename
-            a.href = blobUrl
-            a.click()
-          }).catch(e => {
-            this.$notify({
-              title: '提示',
-              message: '视频下载失败',
-              type: 'warning',
-              duration: 3000
-            })
-          })*/
-        } else {
-          this.$notify({
-            title: '提示',
-            message: resp.msg,
-            type: 'warning',
-            duration: 3000
-          })
-        }
-      }).catch(error => {
-        this.$notify({
-          title: '提示',
-          message: error.message,
-          type: 'error',
-          duration: 3000
-        })
+        if (resp.code === 0) window.open(resp.data.url, '_blank')
       })
     },
     onCreateAlbum() {
-      createAlbum(this.albumForm).then(resp => {
-        if (resp.code === 0) {
-          this.playlist.push(resp.data)
-        }
-      })
+      createAlbum(this.albumForm).then(resp => { if (resp.code === 0) this.playlist.push(resp.data) })
       this.albumForm.albumName = null
     },
     deleteVideo(video) {
-      this.$confirm('确定要删除 ' + video.title + '?', '提示', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning',
-        customClass: 'msgbox'
-      }).then(() => {
-        const videoId = video.videoId
-        const errorReportForm = {
-          videoId: videoId,
-          errorCode: 4
-        }
-
-        videoErrorDelete(errorReportForm).then(resp => {
-          if (resp.code === 0) {
-            this.errorReportForm.errorCode = null
-            this.$notify({
-              title: '提示',
-              message: '视频错误已提交',
-              type: 'warning',
-              duration: 3000
-            })
-          } else {
-            this.$notify({
-              title: '提示',
-              message: resp.msg,
-              type: 'warning',
-              duration: 3000
-            })
-          }
-        }).catch(error => {
-          this.$notify({
-            title: '提示',
-            message: error.message,
-            type: 'warning',
-            duration: 3000
-          })
-        })
-      }).catch(() => {
-        this.$message({
-          type: 'info',
-          message: '已取消'
+      this.$confirm('确定要删除 ' + video.title + '?', '提示', { type: 'warning' }).then(() => {
+        videoErrorDelete({ videoId: video.videoId, errorCode: 4 }).then(resp => {
+          if (resp.code === 0) this.$notify.warning({ title: '提示', message: '视频错误已提交' })
         })
       })
     },
-    displayErrorReportDialog() {
-      this.errorReportForm.videoId = this.video.videoId
-      this.showErrorReportDialog = true
-    },
+    displayErrorReportDialog() { this.errorReportForm.videoId = this.video.videoId; this.showErrorReportDialog = true },
     submitErrorReport() {
-      this.showErrorReportDialog = false
       videoErrorReport(this.errorReportForm).then(resp => {
-        if (resp.code === 0) {
-          this.errorReportForm.errorCode = null
-          this.$notify({
-            title: '提示',
-            message: '视频错误已提交',
-            type: 'warning',
-            duration: 3000
-          })
-        } else {
-          this.$notify({
-            title: '提示',
-            message: resp.msg,
-            type: 'warning',
-            duration: 3000
-          })
-        }
-      }).catch(error => {
-        this.$notify({
-          title: '提示',
-          message: error.message,
-          type: 'warning',
-          duration: 3000
-        })
+        if (resp.code === 0) this.$notify.warning({ title: '提示', message: '视频错误已提交' })
+        this.showErrorReportDialog = false
       })
     },
-    displayShareVideoDialog() {
-      this.showShareVideoDialog = true
-    },
-    displayEditDialog() {
-      this.showEditDialog = true
-    },
-    closeHandle() {
-      // 控制取消和X按钮,关闭弹窗
-      this.showEditDialog = false
-    },
+    displayShareVideoDialog() { this.showShareVideoDialog = true },
+    displayEditDialog() { this.showEditDialog = true },
+    closeHandle() { this.showEditDialog = false },
     submitShareVideo() {
-      this.showShareVideoDialog = false
       getShortUrl(this.video.videoId).then(resp => {
         if (resp.code === 0) {
           const content = window.location.origin + resp.data
-          if (window.clipboardData) {
-            window.clipboardData.setData('text', content)
-          } else {
-            (function() {
-              document.oncopy = function(e) {
-                e.clipboardData.setData('text', content)
-                e.preventDefault()
-                document.oncopy = null
-              }
-            })(content)
-            document.execCommand('Copy')
-          }
+          const input = document.createElement('input'); input.value = content; document.body.appendChild(input);
+          input.select(); document.execCommand('Copy'); document.body.removeChild(input);
           this.$message.info('已成功复制到剪贴板')
-        } else {
-          this.$notify({
-            title: '提示',
-            message: resp.msg,
-            type: 'warning',
-            duration: 3000
-          })
-        }
-      }).catch(error => {
-        this.$notify({
-          title: '提示',
-          message: error.message,
-          type: 'warning',
-          duration: 3000
-        })
-      })
-    },
-    // ****************************************************************************************************************
-    // 评论
-    async submit(newComment, parent, add) {
-      const res = await new Promise((resolve) => {
-        setTimeout(() => {
-          resolve({ newComment, parent })
-        }, 300)
-      })
-
-      add(Object.assign(res.newComment, { postId: this.video.videoId }))
-      if (res.parent !== null) {
-        // console.log('parent: ', res.parent)
-      } else {
-        this.totalSize += 1
-      }
-
-      // console.log('addComment: ', res)
-      publishComment(res).then(resp => {
-        if (resp.code === 0) {
-          this.$notify.success({
-            message: '评论已发布',
-            duration: 3000
-          })
-        } else {
-          this.$notify.warning({
-            message: '评论发布失败',
-            duration: 3000
-          })
-        }
-      })
-    },
-    async like(comment) {
-      const res = await new Promise((resolve) => {
-        setTimeout(() => {
-          resolve(comment)
-        }, 0)
-      })
-
-      console.log('likeComment: ', res)
-    },
-    async uploadImg({ file, callback }) {
-      const res = await new Promise((resolve, reject) => {
-        const reader = new FileReader()
-        reader.readAsDataURL(file)
-        reader.onload = () => {
-          resolve(reader.result)
-        }
-
-        reader.onerror = () => {
-          reject(reader.error)
-        }
-      })
-      callback(res)
-      console.log('uploadImg: ', res)
-    },
-    async deleteComment(comment, parent) {
-      const res = await new Promise((resolve) => {
-        setTimeout(() => {
-          resolve({ comment, parent })
-        }, 300)
-      })
-      console.log('deleteComment: ', res)
-    },
-    // ****************************************************************************************************************
-    getVideoUrl(videoId) {
-      videoUrl(videoId).then(res => {
-        if (res.code === 0) {
-          if (this.userToken != null && this.sendEvent) {
-            // this.wsUrl = 'wss:' + process.env.VUE_APP_SERVER_URL + '/ws/media?userToken=' + this.userToken + '&videoId=' + videoId
-            this.wsUrl = this.getUrl(videoId)
-            this.initWebSocket()
-          }
-
-          const coverUrl = this.video.coverUrl
-          const loginUserId = this.currentUser.userId
-          const urlType = res.data.type
-          if (urlType === 'mp4') {
-            const urls = res.data.urls
-            for (const url of urls) {
-              url.type = 'normal'
-            }
-            this.initMp4Player(loginUserId, videoId, coverUrl, urls, res.data.currentTime)
-          } else if (urlType === 'flv') {
-            const urls = res.data.urls
-            this.initFlvPlayer(loginUserId, videoId, coverUrl, urls, res.data.currentTime)
-          } else {
-            this.$notify.error({
-              message: '视频 url 类型不合法',
-              type: 'warning',
-              duration: 3000
-            })
-          }
-        } else {
-          this.$notify.error({
-            message: '视频 url 获取失败',
-            type: 'warning',
-            duration: 3000
-          })
-        }
-      }).catch(error => {
-        this.$notify.error({
-          message: error.message,
-          type: 'error',
-          duration: 3000
-        })
-      })
-    },
-    getUrl(videoId) {
-      const protocol = location.protocol
-      const hostname = location.hostname
-      const port = location.port
-      let prefix
-      if (protocol === 'https:') {
-        if (port === '' || port === 443) {
-          prefix = 'wss://' + hostname
-        } else {
-          prefix = 'wss://' + hostname + ':' + port
-        }
-      } else {
-        if (port === '' || port === 80) {
-          prefix = 'ws://' + hostname
-        } else {
-          prefix = 'ws://' + hostname + ':' + port
-        }
-      }
-
-      var wsUrl = prefix + '/ws/media?userToken=' + this.userToken + '&videoId=' + videoId
-      return wsUrl
-    },
-    danmakuConfig() {
-      // TODO 获取弹幕配置,将 videoUrl 作为本函数的回调
-    },
-    initMp4Player(userId, videoId, coverUrl, urls, pos) {
-      const player = new DPlayer({
-        container: document.querySelector('#dplayer'),
-        lang: 'zh-cn',
-        screenshot: true,
-        autoplay: false,
-        volume: 0.1,
-        mutex: true,
-        video: {
-          pic: coverUrl,
-          defaultQuality: 0,
-          quality: urls,
-          hotkey: true
-        },
-        danmaku: {
-          id: videoId,
-          maximum: 10000,
-          api: this.danmaku.api,
-          token: this.userToken,
-          bottom: '15%',
-          unlimited: true
-        }
-      })
-
-      // 设置音量
-      // player.volume(0.1, true, false)
-      // 跳转到上次看到的位置
-      player.seek(pos)
-
-      var ended = false
-      /* 事件绑定 */
-      const that = this
-      player.on('play', function() {
-        if (that.sendEvent) {
-          clearInterval(that.intervalEvent)
-          that.intervalEvent = setInterval(() => {
-            if (!ended) {
-              const jsonData = {}
-              jsonData.type = 'progress'
-              jsonData.direction = 'c2s'
-              jsonData.data = {
-                mediaId: videoId,
-                mediaType: 1,
-                currentTime: player.video.currentTime,
-                ended: ended
-              }
-              that.sendJsonMessage(jsonData)
-            }
-          }, 5000)
-        }
-      })
-      player.on('ended', () => {
-        clearInterval(that.intervalEvent)
-        ended = true
-        if (that.sendEvent) {
-          const jsonData = {}
-          jsonData.type = 'progress'
-          jsonData.direction = 'c2s'
-          jsonData.data = {
-            mediaId: videoId,
-            mediaType: 1,
-            currentTime: player.video.currentTime,
-            ended: ended
-          }
-          that.sendJsonMessage(jsonData)
-        }
-      })
-      player.on('volumechange', () => {
-        console.log('声音改变')
-      })
-    },
-    initFlvPlayer(userId, videoId, coverUrl, urls, pos) {
-      const player = new DPlayer({
-        container: document.getElementById('dplayer'),
-        lang: 'zh-cn',
-        screenshot: true,
-        autoplay: false,
-        volume: 0.1,
-        mutex: true,
-        video: {
-          pic: coverUrl,
-          defaultQuality: 0,
-          quality: urls,
-          hotkey: true,
-          type: 'customFlv',
-          customType: {
-            customFlv: function(video, player) {
-              const flvPlayer = flvjs.createPlayer({
-                type: 'flv',
-                url: video.src
-              })
-              flvPlayer.attachMediaElement(video)
-              flvPlayer.load()
-            }
-          }
         }
+        this.showShareVideoDialog = false
       })
-
-      // 跳转到上次看到的位置
-      player.seek(pos)
-      /* 事件绑定 */
-      player.on('ended', () => {
-      })
-      player.on('volumechange', () => {
-        console.log('声音改变')
-      })
-    },
-    // ****************************************************************************************************************
-    // websocket
-    initWebSocket() {
-      if ('WebSocket' in window) {
-        this.wsClient = new WebSocket(this.wsUrl)
-        const that = this
-        this.wsClient.onopen = function() {
-          that.setOnline()
-        }
-        this.wsClient.onclose = function() {
-          that.setOffline()
-          that.reconnect()
-        }
-        this.wsClient.onerror = function() {
-          that.setOffline()
-          console.log('websocket connection error...')
-          that.reconnect()
-        }
-        this.wsClient.onmessage = function(evt) {
-          that.processMessage(evt.data)
-        }
-      } else {
-        // 浏览器不支持 WebSocket
-        this.$message.error('您的浏览器不支持 WebSocket!')
-      }
-    },
-    setOnline() {
-      this.wsConnectStatus = true
-    },
-    setOffline() {
-      this.wsConnectStatus = false
-      this.realtimeViewCount = 0
-    },
-    reconnect() {
-      if (this.wsReconnectLock) return
-      this.wsReconnectLock = true
-      const that = this
-      setTimeout(function() {
-        console.log('websocket reconnecting...')
-        that.initWebSocket()
-        that.wsReconnectLock = false
-      }, 5000)
-    },
-    sendJsonMessage(message) {
-      const jsonStr = JSON.stringify(message)
-      this.wsClient.send(jsonStr)
-    },
-    processMessage(message) {
-      const jsonMessage = JSON.parse(message)
-      this.realtimeViewCount = jsonMessage.viewCount
     }
   }
 }
 </script>
 
 <style scoped>
-/*处于手机屏幕时*/
-@media screen and (max-width: 768px) {
-  .movie-list {
-    padding-top: 8px;
-    padding-left: 0.5%;
-    padding-right: 0.5%;
-  }
-  /* 或者使用这种更具体的选择器 */
-  .el-dialog__wrapper .maintenance-dialog {
-    width: 100% !important;
-    margin-top: 5vh !important;
-  }
-
-  /* 表单布局优化 */
-  .maintenance-dialog .el-form-item {
-    margin-bottom: 15px;
-  }
+.video-page-container { padding: 20px; background-color: #f4f4f5; min-height: 100vh; }
+.shadow-box { box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05) !important; border: none !important; border-radius: 8px !important; background: #fff; }
+.player-section { background: #1a1a1a; padding: 20px; margin-bottom: 20px; }
+.video-header { margin-bottom: 15px; }
+.video-title { color: #fff; font-size: 20px; margin: 0 0 10px 0; font-weight: 600; }
+.video-meta { display: flex; justify-content: space-between; align-items: center; color: #999; font-size: 13px; }
+.meta-item { margin-right: 20px; }
+.video-content { background: #000; border-radius: 4px; overflow: hidden; margin-bottom: 15px; }
+.bili-link { color: inherit; text-decoration: none; }
+.info-card { margin-bottom: 20px; padding: 10px; }
+.video-description { font-size: 14px; color: #606266; line-height: 1.6; white-space: pre-wrap; }
+.video-tags { margin-top: 20px; padding-top: 15px; border-top: 1px solid #ebeef5; }
+.video-tag-item { margin-right: 10px; margin-bottom: 8px; border: none; background: #f0f2f5; }
+.video-tag-item a { text-decoration: none; color: #409EFF; }
+.side-container { position: sticky; top: 20px; }
+.recommend-header { display: flex; justify-content: space-between; align-items: center; }
+.recommend-header .title { font-weight: 600; font-size: 16px; }
+.auto-play { font-size: 12px; color: #909399; display: flex; align-items: center; }
+.auto-play span { margin-right: 8px; }
+.side-card-wrapper { margin-bottom: 12px; transition: transform 0.2s; }
+.side-card-wrapper:hover { transform: translateX(5px); }
+.new-album-input { margin-top: 20px; }
+.share-preview { text-align: center; }
+.share-cover { width: 100%; height: 200px; border-radius: 8px; margin-bottom: 15px; }
+.mb-20 { margin-bottom: 20px; }
 
-  .maintenance-dialog .el-form-item__label {
-    float: none !important;
-    display: block !important;
-    text-align: left !important;
-    padding: 0 0 8px !important;
-    width: 100% !important;
-  }
-
-  .maintenance-dialog .el-form-item__content {
-    margin-left: 0 !important;
-  }
-
-  /* 确保所有输入控件宽度100% */
-  .maintenance-dialog .el-input,
-  .maintenance-dialog .el-select,
-  .maintenance-dialog .el-date-picker,
-  .maintenance-dialog .el-input-number {
-    width: 100% !important;
-  }
-
-  /* 调整按钮组间距 */
-  .maintenance-dialog .dialog-footer {
-    text-align: center;
-  }
-
-  .maintenance-dialog .dialog-footer .el-button {
-    margin: 0 5px;
-  }
-}
-
-.movie-list {
-  padding-top: 5px;
-  padding-bottom: 5px;
-  padding-left: 5px;
-  padding-right: 5px;
-}
-
-.clearfix:before,
-.clearfix:after {
-  display: table;
-  content: "";
-}
-
-.clearfix:after {
-  clear: both;
-}
-
-.tag{
-  padding-top: 1px;
-  padding-bottom: 1px;
-  margin-left: 3px;
-  margin-right: 3px;
-}
-
-.imgs {
-  position: relative;
-}
-
-.coverImg {
-  width: 100%;
-  height: 175px;
-  display: block;
+@media screen and (max-width: 768px) {
+  .video-page-container { padding: 10px; }
+  .video-title { font-size: 16px; }
+  .meta-right { display: none; }
 }
 </style>