reghao 2 лет назад
Родитель
Сommit
418ac7e990

+ 5 - 1
src/api/comment.js

@@ -1,7 +1,7 @@
 import { get, post } from '@/utils/request'
 
 const commentApi = {
-  videoCommentApi: '/api/comment/video',
+  videoCommentApi: '/api/comment/video'
 }
 
 // 获取视频评论
@@ -9,6 +9,10 @@ export function getVideoComment(videoId) {
   return get(commentApi.videoCommentApi + '?prevId=1&nextId=2&videoId=' + videoId)
 }
 
+export function childComment(videoId) {
+  return get(commentApi.videoCommentApi + '?prevId=1&nextId=2&videoId=' + videoId)
+}
+
 // 发布视频评论
 export function publishVideoComment(data) {
   return post(commentApi.videoCommentApi, data)

+ 0 - 214
src/components/comment/Comment.vue

@@ -1,214 +0,0 @@
-<template>
-  <div id="comment">
-    <!--评论条数-->
-    <el-row
-      style="font-size: 20px; padding-top: 10px"
-    >{{ comments.length }}条评论</el-row>
-    <!--评论框-->
-    <el-row type="flex" align="middle" class="content">
-      <el-col :span="2">
-        <img
-          v-if="!user"
-          class="u-avatar"
-          src="@/assets/img/icon/avatar.png"
-          alt=""
-        >
-        <img v-else class="u-avatar" :src="user.avatarurl" alt="">
-      </el-col>
-      <el-col :span="15" :offset="1">
-        <el-input
-          v-model="content"
-          type="textarea"
-          :rows="3"
-          :disabled="!user"
-          :placeholder="user ? '请输入评论' : '登陆后才可评论!'"
-        />
-      </el-col>
-      <el-col :span="6" :offset="1">
-        <el-button
-          type="primary"
-          :disabled="!user"
-          @click.native="addComment"
-        >发布评论</el-button>
-      </el-col>
-    </el-row>
-    <!--评论内容-->
-    <el-row
-      v-if="comments.length === 0"
-      style="height: 100px"
-      type="flex"
-      align="middle"
-      justify="center"
-    >暂无评论!</el-row>
-    <el-row v-for="(comment, index) in comments" :key="index" class="content">
-      <el-col :span="2">
-        <img class="u-avatar" :src="comment.avatarurl">
-      </el-col>
-      <el-col :span="21" :offset="1">
-        <div class="u-nickname">{{ comment.nickname }}</div>
-        <span class="time">{{ getDate(comment.date) }}</span>
-        	<el-popover
-								placement="top"
-								width="150"
-								trigger="hover">
-					<p>确定删除评论?</p>
-					<div style="text-align: right; margin: 0">
-						<el-button type="plain" size="mini" @click="deleteComment(comment.commentId)">确定</el-button>
-					</div>
-					<i v-if="comment.uid === user.uid" class="el-icon-delete" slot="reference">删除</i>
-				</el-popover>
-        <div class="comment">
-          {{ comment.content }}
-        </div>
-      </el-col>
-    </el-row>
-  </div>
-</template>
-
-<script>
-import { formatDate } from 'assets/js/utils'
-import { getVideoComment, publishVideoComment } from '@/api/comment'
-
-export default {
-  name: 'Comment',
-  components: {},
-  props: {
-    videoId: String
-  },
-  data() {
-    return {
-      content: '',
-      comments: [],
-      user: {
-        uid: 10001,
-        nickname: '大西瓜',
-        avatarurl: 'https://picx.zhimg.com/v2-ed8c334e05383b3e914e6a31dd955af8_xl.jpg'
-      }
-    }
-  },
-  created() {
-    this.comments = [
-      {
-        uid: 10001,
-        nickname: '大西瓜',
-        avatarurl: 'https://picx.zhimg.com/v2-ed8c334e05383b3e914e6a31dd955af8_xl.jpg',
-        content: 'hha',
-        date: new Date()
-      },
-      {
-        uid: 10002,
-        nickname: '草莓',
-        avatarurl: 'https://picx.zhimg.com/v2-77acf2ee1f90de86221acbbbfd7967ae_xl.jpg',
-        content: 'bbc',
-        date: new Date()
-      },
-      {
-        uid: 10003,
-        nickname: '大芒果',
-        avatarurl: 'https://picx.zhimg.com/v2-6fd63e002c1b9b7ee99cf635608f9467_xl.jpg',
-        content: 'kka',
-        date: new Date()
-      }
-    ]
-
-    // 加载当前视频的评论
-    getVideoComment(this.videoId).then(res => {
-      console.log('获取视频评论')
-      if (res.code === 0) {
-        console.log(res)
-      }
-    })
-  },
-  methods: {
-    getDate(date) {
-      return formatDate(new Date(date), 'yyyy-MM-dd hh:mm:ss')
-    },
-    // 添加评论
-    addComment() {
-      /*this.$message.info('发布评论成功!')
-      this.comments.splice(0, 0, {
-        nickname: '大芒果',
-        avatarurl: 'https://picx.zhimg.com/v2-6fd63e002c1b9b7ee99cf635608f9467_xl.jpg',
-        content: '一扫而散',
-        date: new Date()
-      })
-      this.content = ''*/
-
-      if (this.user) {
-        console.log('发布评论')
-
-        if (this.content === '' || this.content === null) {
-          this.$message.warning('评论内容不能为空!')
-          return
-        }
-
-        publishVideoComment({
-          uid: this.user.uid,
-          videoId: this.videoId,
-          content: this.content
-        }).then((res) => {
-          if (res.code === 0) {
-            this.$message.info('评论已发布!')
-            this.comments.splice(0, 0, {
-              avatarurl: this.user.avatarurl,
-              nickname: this.user.nickname,
-              content: this.content,
-              date: new Date()
-            })
-            this.content = ''
-          }
-        })
-      }
-    },
-    // 删除当前评论
-    deleteComment(contentId) {
-      console.log('删除评论')
-      // 删除评论
-    	// deleteCommentById(contentId)
-    }
-  }
-}
-</script>
-
-<style scoped>
-#comment {
-  border-top: 1px solid #9d9d9d;
-  margin-top: 20px;
-}
-
-.u-avatar {
-  width: 90%;
-  border-radius: 50%;
-}
-
-.u-nickname {
-  display: inline;
-  cursor: pointer;
-  color: #66b1ff;
-}
-
-.time {
-  margin-left: 10px;
-  font-size: 15px;
-  color: #9d9d9d;
-}
-
-.comment {
-  padding-top: 5px;
-  font-size: 20vm;
-  line-height: 1.7;
-  padding-bottom: 15px;
-  border-bottom: 1px solid #9d9d9d;
-}
-
-.content {
-  margin-top: 20px;
-  padding-bottom: 20px;
-}
-
-.el-icon-delete {
-  cursor: pointer;
-  margin-left: 10px;
-  color: #9d9d9d;
-}
-</style>

+ 449 - 0
src/components/comment/components/CommentForm.vue

@@ -0,0 +1,449 @@
+<template>
+  <div :class="`${className} comment-form`">
+    <div class="avatar-box">
+      <slot />
+    </div>
+
+    <div class="form-box">
+      <div class="rich-input" :class="{ focus: focus || value }">
+        <div class="grow-wrap" :data-replicated-value="value">
+          <textarea
+            ref="input"
+            rows="1"
+            :value="value"
+            :placeholder="placeholder"
+            @input="(e) => (value = e.target.value)"
+            @focus="focus = true"
+            @blur="handleBlur"
+            @mousedown="closeEmojiSelector"
+          />
+        </div>
+        <div v-show="imgSrc" ref="image-preview-box" class="image-preview-box">
+          <div
+            v-show="imgSrc"
+            :style="`background-image: url(${imgSrc})`"
+            class="image"
+          />
+          <div class="clean-btn" @mousedown.prevent="removeImg">
+            <svg
+              aria-hidden="true"
+              width="15"
+              height="15"
+              viewBox="0 0 21 21"
+              class="icon close-icon"
+            >
+              <g fill="none" fill-rule="evenodd" transform="translate(1 1)">
+                <circle
+                  cx="9.5"
+                  cy="9.5"
+                  r="9.5"
+                  fill="#000"
+                  stroke="#FFF"
+                  opacity=".5"
+                />
+                <path
+                  fill="#FFF"
+                  d="M13.743 5.964L10.207 9.5l3.536 3.536-.707.707L9.5 10.207l-3.536 3.536-.707-.707L8.793 9.5 5.257 5.964l.707-.707L9.5 8.793l3.536-3.536z"
+                />
+              </g>
+            </svg>
+          </div>
+        </div>
+      </div>
+      <div
+        v-show="focus || value || imgSrc"
+        class="option-box"
+        @mousedown.prevent="closeEmojiSelector($refs.input.focus())"
+      >
+        <div
+          class="emoji emoji-btn"
+          @mousedown.prevent.stop="openEmojiSelector"
+        >
+          <div class="emoji-box">
+            <div class="icon" />
+            <span>表情</span>
+          </div>
+          <EmojiSelector
+            v-show="showEmojiSelector"
+            @choose="(v) => (value += v)"
+          />
+        </div>
+        <div class="image-btn" @mousedown.prevent="triggerUpload">
+          <svg
+            aria-hidden="true"
+            width="22"
+            height="22"
+            viewBox="0 0 22 22"
+            class="icon image-icon"
+          >
+            <g fill="none" fill-rule="evenodd">
+              <path d="M1 1h20v20H1z" />
+              <g transform="translate(2 3)">
+                <path
+                  stroke="#027FFF"
+                  stroke-width=".9"
+                  d="M2.28.667h13.44c1.075 0 1.947.871 1.947 1.946v10.774a1.947 1.947 0 0 1-1.947 1.946H2.28a1.947 1.947 0 0 1-1.947-1.946V2.613c0-1.075.872-1.946 1.947-1.946zM.333 12.499L5 8l9.01 7.333m-6.343-4.842L10.333 8l7.136 5.914"
+                />
+                <circle cx="13.5" cy="4.5" r="1.5" fill="#027FFF" />
+              </g>
+            </g>
+          </svg>
+          <span>图片</span>
+          <input
+            ref="upload"
+            class="upload-file"
+            type="file"
+            @change="handleChange"
+            @click="onUpload = true"
+          />
+        </div>
+        <slot name="submitBtn">
+          <button
+            class="submit-btn"
+            :disabled="!value && !imgSrc"
+            @click.stop="handleSubmit"
+          >
+            评论
+          </button>
+        </slot>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import EmojiSelector from './EmojiSelector'
+export default {
+  name: 'CommentForm',
+  components: { EmojiSelector },
+  props: {
+    placeholder: {
+      type: String,
+      default: '输入评论...',
+    },
+    id: {
+      type: [String, Number],
+      default: 'comment-root',
+    },
+    comment: {
+      type: Object,
+      default: () => {},
+    },
+    parent: {
+      type: Object,
+      default: () => {},
+    },
+    uploadImg: {
+      type: Function,
+      default: null,
+    },
+  },
+  data() {
+    return {
+      focus: false, // * 聚焦状态
+      value: '', // * 输入框值
+      imgSrc: '', // * 粘贴的图片src
+      showEmojiSelector: false, // * 表情选择框状态
+    }
+  },
+  computed: {
+    // 是否为顶部评论表单
+    isRoot() {
+      return this.id === 'comment-root'
+    },
+    // 是否为回复中的表单
+    isSub() {
+      return this.id.split('-').length === 3
+    },
+    className() {
+      return this.isRoot
+        ? 'comment-root'
+        : this.isSub
+        ? 'reply sub-reply'
+        : 'reply'
+    },
+  },
+  mounted() {
+    const richInput = this.$refs.input
+    !this.isRoot && richInput.focus()
+
+    richInput.addEventListener('paste', this.handlePaste)
+    this.$once('hook:beforeDestroy', () =>
+      richInput.removeEventListener('paste', this.handlePaste)
+    )
+  },
+  methods: {
+    // * 选择要上传的图片
+    handleChange(e) {
+      const files = e.target.files
+      if (!(files && files[0])) return
+      this.beforeSetImg(files[0])
+    },
+    // * 处理图片
+    async beforeSetImg(file) {
+      if (!/^image/.test(file.type)) {
+        throw new Error("file type must contain 'image'.")
+      }
+
+      if (typeof this.uploadImg === 'function') {
+        const callback = (src) => {
+          this.imgSrc = src
+        }
+        await this.uploadImg({ file, callback })
+        return
+      }
+
+      const reader = new FileReader()
+      reader.readAsDataURL(file)
+      reader.onload = () => {
+        this.imgSrc = reader.result
+      }
+      reader.onerror = () => {
+        throw new Error(
+          `read file errored, the error code is ${reader.error.code}.`
+        )
+      }
+    },
+    // * 点击图片触发上传
+    triggerUpload() {
+      this.$refs.upload.click()
+    },
+    // * 点击图片上的删除按钮
+    removeImg() {
+      this.imgSrc = ''
+      this.closeEmojiSelector()
+    },
+    // * textarea blur 事件
+    handleBlur(e) {
+      this.showEmojiSelector = false
+
+      if (this.onUpload) {
+        this.$nextTick(() => {
+          this.onUpload = false
+        })
+        return
+      }
+
+      if (this.value || this.imgSrc) return
+
+      this.focus = false
+
+      if (!this.isRoot) {
+        this.close()
+      }
+    },
+    // * textarea paste 事件
+    handlePaste(e) {
+      const file = e.clipboardData.files[0]
+      if (file) {
+        // 只处理复制图片
+        this.beforeSetImg(file)
+        e.preventDefault()
+      }
+    },
+    // * 点击评论
+    handleSubmit() {
+      if (!this.value.trim() && !this.imgSrc) return
+      const user = (this.comment && this.comment.user) || null
+
+      const data = {
+        id: this.id,
+        content: this.value,
+        imgSrc: this.imgSrc,
+        reply: (this.isSub && JSON.parse(JSON.stringify(user))) || null,
+        createAt: new Date().getTime(),
+        likes: 0,
+        callback: () => {
+          this.isRoot ? this.reset() : this.close()
+        },
+      }
+
+      if (!this.isSub) {
+        data.children = []
+      }
+
+      this.$emit('form-submit', { newComment: data, parent: this.parent })
+    },
+    // * 重置组件状态
+    reset() {
+      this.value = ''
+      this.imgSrc = ''
+      this.$refs.input.blur()
+    },
+    // * 销毁组件
+    close() {
+      this.$emit('form-delete', this.id)
+    },
+    // * 选择表情
+    openEmojiSelector() {
+      this.showEmojiSelector = !this.showEmojiSelector
+
+      if (document.activeElement === document.body) {
+        this.$refs.input.focus()
+      }
+      if (this.showEmojiSelector) {
+        // 移动光标到末尾
+        const input = this.$refs.input
+        input.selectionStart = input.selectionEnd = this.value.length
+      }
+    },
+    // * 关闭选择表情组件
+    closeEmojiSelector() {
+      if (this.showEmojiSelector) {
+        this.showEmojiSelector = false
+      }
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+.comment-form {
+  max-width: 100%;
+  padding: 0.8rem 1.0664rem;
+  display: flex;
+  background-color: #fafbfc;
+  border-radius: 3px;
+  .avatar-box {
+    flex: 0 0 auto;
+    img {
+      margin: 0 0.8rem 0 0;
+    }
+  }
+  .form-box {
+    flex: 1 1 auto;
+    .rich-input {
+      border-radius: 3px;
+      border: 1px solid #f1f1f1;
+      background-color: #fff;
+      overflow: hidden;
+      &.focus {
+        border-color: #007fff;
+      }
+      .grow-wrap {
+        display: grid;
+        &::after {
+          content: attr(data-replicated-value) ' ';
+          white-space: pre-wrap;
+          visibility: hidden;
+        }
+        textarea {
+          outline: none;
+          border: none;
+          resize: none;
+          touch-action: none;
+          overflow: hidden;
+          &::placeholder {
+            color: #c2c2c2;
+          }
+        }
+        & > textarea,
+        &::after {
+          font: inherit;
+          grid-area: 1 / 1 / 2 / 2;
+          padding: 0.48rem 0.8rem;
+          min-height: 1.04rem;
+          line-height: 1.7;
+          font-size: 0.8664rem;
+          color: #17181a;
+          box-sizing: border-box;
+          word-break: break-all;
+        }
+      }
+
+      .image-preview-box {
+        display: inline-block;
+        position: relative;
+        margin: 0 0.8rem 0.4rem;
+        .image {
+          width: 5.3336rem;
+          height: 5.3336rem;
+          background-repeat: no-repeat;
+          background-size: cover;
+          background-position: 50%;
+        }
+        .clean-btn {
+          position: absolute;
+          top: 0.15rem;
+          right: 0.2rem;
+          cursor: pointer;
+        }
+      }
+    }
+    .option-box {
+      margin-top: 0.52rem;
+      display: flex;
+      align-items: center;
+      color: #027fff;
+      font-size: 0.8664rem;
+      .emoji {
+        position: relative;
+        .emoji-box {
+          display: flex;
+          align-items: center;
+          cursor: pointer;
+          .icon {
+            width: 1.2rem;
+            height: 1.2rem;
+            background-repeat: no-repeat;
+            background-size: cover;
+            background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMiIgaGVpZ2h0PSIyMiIgdmlld0JveD0iMCAwIDIyIDIyIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZD0iTTEgMWgyMHYyMEgxeiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiMwMjdGRkYiIGZpbGwtcnVsZT0ibm9uemVybyIgZD0iTTExIDE4LjQzOGE3LjQzOCA3LjQzOCAwIDEgMCAwLTE0Ljg3NiA3LjQzOCA3LjQzOCAwIDAgMCAwIDE0Ljg3NnptMCAxLjA2MmE4LjUgOC41IDAgMSAxIDAtMTcgOC41IDguNSAwIDAgMSAwIDE3ek03LjgxMiA5LjkzN2ExLjA2MiAxLjA2MiAwIDEgMCAwLTIuMTI0IDEuMDYyIDEuMDYyIDAgMCAwIDAgMi4xMjV6bTYuMzc1IDBhMS4wNjMgMS4wNjMgMCAxIDAgMC0yLjEyNSAxLjA2MyAxLjA2MyAwIDAgMCAwIDIuMTI1ek0xMSAxNi4yMzJhMy4yNyAzLjI3IDAgMCAwIDMuMjctMy4yN0g3LjczYTMuMjcgMy4yNyAwIDAgMCAzLjI3IDMuMjd6Ii8+CiAgICA8L2c+Cjwvc3ZnPgo=');
+          }
+          &:hover {
+            opacity: 0.8;
+          }
+        }
+      }
+      .image-btn {
+        flex: 0 0 auto;
+        display: flex;
+        align-items: center;
+        margin-left: 20px;
+        cursor: pointer;
+        .icon {
+          margin-right: 0.2664rem;
+          width: 1.2rem;
+          height: 1.2rem;
+        }
+        &:hover {
+          opacity: 0.8;
+        }
+      }
+      .upload-file {
+        display: none;
+      }
+      .submit-btn {
+        flex: 0 0 auto;
+        margin-left: auto;
+        padding: 0.4rem 1.04rem;
+        font-size: 1rem;
+        color: #fff;
+        background-color: #027fff;
+        border-radius: 2px;
+        outline: none;
+        border: none;
+        cursor: pointer;
+        transition: all 0.3s;
+        &:hover {
+          background-color: #0371df;
+        }
+        &:disabled {
+          opacity: 0.4;
+          cursor: default;
+        }
+      }
+    }
+  }
+  &.reply {
+    margin-top: 0.8664rem;
+    padding: 0.8rem;
+    &.sub-reply {
+      background-color: #fff;
+      border: 1px solid #f1f1f2;
+    }
+    .avatar-box {
+      display: none;
+    }
+  }
+}
+</style>

+ 299 - 0
src/components/comment/components/CommentItem.vue

@@ -0,0 +1,299 @@
+<template>
+  <div class="comment-item" :class="{ 'sub-comment-item': isSubComment }">
+    <div class="comment">
+      <!-- 评论或回复人头像 -->
+      <img
+        class="avatar"
+        :src="comment.user.avatar || ''"
+        @error="(e) => e.target.classList.add('error')"
+      >
+      <div class="content-box">
+        <!-- 评论或回复人具体信息 -->
+        <div class="meta-box">
+          <slot name="userMeta">
+            <div class="user-popover-box">
+              <span v-if="comment.user">{{
+                comment.user.name +
+                  (comment.user.author === true ? '(作者)' : '')
+              }}</span>
+            </div>
+          </slot>
+        </div>
+
+        <!-- 评论或回复内容 -->
+        <div class="content">
+          <span
+            v-if="comment.reply"
+            class="reply"
+          >回复
+            <span class="reply-target" :title="comment.reply.email">{{
+              comment.reply.name + ':'
+            }}</span>
+          </span>
+          {{ comment.content }}
+          <div v-if="comment.imgSrc" class="img-box">
+            <img
+              :src="comment.imgSrc || ''"
+              @error="(e) => e.target.classList.add('error')"
+            >
+          </div>
+        </div>
+
+        <!-- 评论或回复时间及操作 -->
+        <div class="reply-stat">
+          <time
+            :title="formatTime(comment.createAt, true)"
+            :datetime="comment.createAt"
+          >{{ formatTime(comment.createAt) }}</time>
+          <div
+            v-if="user.author === true"
+            class="delete"
+            @click.stop="$emit('comment-delete', { id, comment, parent })"
+          >
+            <span>·</span>删除
+          </div>
+          <div class="action-box">
+            <div
+              class="like-action action"
+              :class="{ active: comment.liked }"
+              @click.stop="$emit('comment-like', { id, comment })"
+            >
+              <svg
+                aria-hidden="true"
+                viewBox="0 0 20 20"
+                class="icon like-icon"
+              >
+                <g fill="none" fill-rule="evenodd">
+                  <path d="M0 0h20v20H0z" />
+                  <path
+                    :stroke="comment.liked ? '#37C700' : '#8A93A0'"
+                    stroke-linejoin="round"
+                    :fill="comment.liked ? '#37c700' : 'none'"
+                    d="M4.58 8.25V17h-1.4C2.53 17 2 16.382 2 15.624V9.735c0-.79.552-1.485 1.18-1.485h1.4zM11.322 2c1.011.019 1.614.833 1.823 1.235.382.735.392 1.946.13 2.724-.236.704-.785 1.629-.785 1.629h4.11c.434 0 .838.206 1.107.563.273.365.363.84.24 1.272l-1.86 6.513A1.425 1.425 0 0 1 14.724 17H6.645V7.898C8.502 7.51 9.643 4.59 9.852 3.249A1.47 1.47 0 0 1 11.322 2z"
+                  />
+                </g>
+              </svg>
+              <span v-show="comment.likes" class="action-title">{{
+                comment.likes
+              }}</span>
+            </div>
+            <div
+              class="comment-action action"
+              @mousedown.prevent="$emit('comment-reply', id)"
+              @click.prevent
+            >
+              <svg
+                aria-hidden="true"
+                viewBox="0 0 20 20"
+                class="icon comment-icon"
+              >
+                <g fill="none" fill-rule="evenodd">
+                  <path d="M0 0h20v20H0z" />
+                  <path
+                    stroke="#8A93A0"
+                    stroke-linejoin="round"
+                    d="M10 17c-4.142 0-7.5-2.91-7.5-6.5S5.858 4 10 4c4.142 0 7.5 2.91 7.5 6.5 0 1.416-.522 2.726-1.41 3.794-.129.156.41 3.206.41 3.206l-3.265-1.134c-.998.369-2.077.634-3.235.634z"
+                  />
+                </g>
+              </svg>
+              <span class="action-title">回复</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 评论表单组件 -->
+        <slot :id="id" />
+
+        <!-- 回复列表 -->
+        <slot name="subList" :parentId="id" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'CommentItem',
+  props: {
+    comment: {
+      type: Object,
+      default: () => {},
+      required: true
+    },
+    id: {
+      type: [String, Number],
+      required: true
+    },
+    parent: {
+      type: Object,
+      default: () => {}
+    },
+    user: {
+      type: Object,
+      default: () => {}
+    }
+  },
+  computed: {
+    isSubComment() {
+      return this.id.split('-').length === 3
+    }
+  },
+  methods: {
+    formatTime(time, local = false) {
+      const d = new Date(time)
+
+      if (local) {
+        return d.toString()
+      }
+
+      const now = Date.now()
+      const diff = (now - d) / 1000
+
+      switch (true) {
+        case diff < 30:
+          return '刚刚'
+        case diff < 3600:
+          return Math.ceil(diff / 60) + '分钟前'
+        case diff < 3600 * 24:
+          return Math.ceil(diff / 3600) + '小时前'
+        case diff < 3600 * 24 * 30:
+          return Math.floor(diff / 3600 / 24) + '天前'
+        case diff < 3600 * 24 * 365:
+          return Math.floor(diff / 3600 / 24 / 30) + '月前'
+        default:
+          return Math.floor(diff / 3600 / 24 / 365) + '年前'
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.comment-item {
+  margin-bottom: 1.0664rem;
+  &:not(:last-child) {
+    .content-box {
+      border-bottom: 1px solid #f1f1f1;
+    }
+  }
+  &:hover {
+    .comment .reply-stat .delete {
+      visibility: visible;
+    }
+  }
+  .comment {
+    display: flex;
+    .content-box {
+      margin-left: 0.6664rem;
+      flex: 1 1 auto;
+      &.focus {
+        padding-bottom: 0.4rem;
+      }
+      .meta-box {
+        display: flex;
+        align-items: center;
+        font-size: 0.8664rem;
+        line-height: 1.25;
+        white-space: nowrap;
+        .user-popover-box {
+          cursor: pointer;
+        }
+      }
+      .content {
+        margin-top: 0.44rem;
+        font-size: 0.8664rem;
+        line-height: 1.4664rem;
+        white-space: pre-line;
+        word-break: break-all;
+        color: #505050;
+        overflow: hidden;
+        .img-box {
+          margin-top: 0.5rem;
+          img {
+            max-width: 100%;
+            max-height: 20rem;
+            object-fit: cover;
+          }
+        }
+        .reply {
+          vertical-align: top;
+        }
+        .reply-target {
+          cursor: pointer;
+          color: #406599;
+        }
+      }
+    }
+    .reply-stat {
+      display: flex;
+      margin-top: 7px;
+      font-weight: 400;
+      time,
+      .delete {
+        font-size: 0.8664rem;
+        color: #8a9aa9;
+      }
+      .delete {
+        visibility: hidden;
+        cursor: pointer;
+        span {
+          margin: 0 0.2rem;
+        }
+      }
+      .action-box {
+        flex: 0 0 auto;
+        display: flex;
+        justify-content: space-between;
+        margin-left: auto;
+        min-width: 7.04rem;
+        color: #8a93a0;
+        user-select: none;
+        .action {
+          display: flex;
+          align-items: center;
+          margin-left: 0.4rem;
+          cursor: pointer;
+          &:hover {
+            opacity: 0.8;
+          }
+          &.active {
+            color: #37c700;
+          }
+          .icon {
+            min-width: 16.5px;
+            min-height: 16.5px;
+            width: 0.8rem;
+            height: 0.8rem;
+          }
+          .action-title {
+            margin-left: 0.2rem;
+            font-size: 0.8rem;
+          }
+        }
+      }
+    }
+  }
+}
+
+.sub-comment-list {
+  margin: 0.8rem 0;
+  padding: 0 0 0 0.8rem;
+  background-color: #fafbfc;
+  border-radius: 3px;
+  .comment-item {
+    margin-bottom: 0;
+    &:last-child .content-box {
+      border-bottom: none;
+    }
+    .comment {
+      position: relative;
+      padding: 0.8rem 0 0;
+
+      .content-box {
+        margin-right: 0.8rem;
+        padding-bottom: 0.8rem;
+      }
+    }
+  }
+}
+</style>

+ 12 - 0
src/components/comment/components/CommentList.js

@@ -0,0 +1,12 @@
+export default {
+  props: {
+    sub: {
+      type: Boolean,
+      default: false
+    }
+  },
+  render(h) {
+    const className = this.sub ? 'sub-comment-list' : 'comment-list'
+    return h('div', { class: className }, this.$slots.default)
+  }
+}

+ 264 - 0
src/components/comment/components/EmojiSelector.vue

@@ -0,0 +1,264 @@
+<template>
+  <div class="emoji-selector" @mousedown.prevent @mousedown.stop>
+    <div class="triangle" />
+    <div class="emoji-content">
+      <div class="category">
+        <div
+          v-for="(item, i) in currentEmojis"
+          :key="`emoji-${i}`"
+          class="item"
+          @click="$emit('choose', item)"
+        >
+          {{ item }}
+        </div>
+      </div>
+    </div>
+    <div class="next-page">
+      <div
+        v-for="cat in Object.keys(emojis)"
+        :key="cat"
+        :class="{ active: currentCat === cat }"
+        @click="currentCat = cat"
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'ImojiSelector',
+  data() {
+    return {
+      currentCat: 'FrequentlyUsed',
+      emojis: {
+        FrequentlyUsed: [
+          '😃',
+          '😘',
+          '😂',
+          '😳',
+          '😍',
+          '👏',
+          '👍',
+          '👎',
+          '😁',
+          '😉',
+          '😠',
+          '😞',
+          '😥',
+          '😭',
+          '😝',
+          '😡',
+          '❤',
+          '💔',
+          '😣',
+          '😔',
+          '😄',
+          '😷',
+          '😚',
+          '😓',
+          '😊',
+          '😢',
+          '😜',
+          '😨',
+          '😰',
+          '😲',
+          '😏',
+          '😱',
+          '😪',
+          '😖',
+          '😌',
+          '😒',
+          '👻',
+          '🎅',
+          '👧',
+          '👦',
+          '👩',
+          '👨',
+          '🐶',
+          '🐱',
+          '👊',
+          '✊',
+          '✌',
+          '💪',
+          '👆',
+          '👇',
+          '👉',
+          '👈',
+          '👌',
+          '💩',
+        ],
+        Symbols0: [
+          '🤗',
+          '😎',
+          '🤓',
+          '👩‍💻',
+          '👨‍💻',
+          '🙄',
+          '😭',
+          '😨',
+          '🤪',
+          '🎉',
+          '🤔',
+          '🐵',
+          '😇',
+          '🤬',
+          '🐈',
+          '😹',
+          '🙀',
+          '🇨🇳',
+          '👮',
+          '🐕',
+          '✅',
+          '👋',
+          '🔥',
+          '🐛',
+          '🍉',
+          '👽',
+          '🤖',
+          '⌚',
+          '🤝',
+          '🏳️‍🌈',
+          '🚩',
+          '💤',
+          '®',
+          '©',
+          '💯',
+          '™',
+          '💻',
+          '📅',
+          '📌',
+          '✉',
+          '⌨',
+          '📗',
+          '🤳',
+          '🛌',
+          '🎣',
+          '🎨',
+          '🎧',
+          '🎸',
+          '🎤',
+          '🏸',
+          '🏀',
+          '⚽',
+          '🎮',
+          '🏊',
+        ],
+        Symbols1: [
+          '🍗',
+          '🦄',
+          '🔞',
+          '🙏',
+          '☀',
+          '🌙',
+          '🌟',
+          '⚡',
+          '☁',
+          '☔',
+          '🍁',
+          '🌻',
+          '🍃',
+          '👗',
+          '🎀',
+          '👄',
+          '🌹',
+          '☕',
+          '🎂',
+          '🕙',
+          '🍺',
+          '🔍',
+          '📱',
+          '🏠',
+          '🚗',
+          '🎁',
+          '⚽',
+          '💣',
+          '💎',
+          '💊',
+          '🤮',
+          '🏆',
+          '👿',
+        ],
+      },
+    }
+  },
+  computed: {
+    currentEmojis() {
+      return this.emojis[this.currentCat]
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+.emoji-selector {
+  padding: 0.8rem;
+  position: absolute;
+  top: 2.24rem;
+  z-index: 1;
+  bottom: 0;
+  width: 19rem;
+  height: 14rem;
+  border-radius: 2px;
+  background-color: #fff;
+  box-shadow: 0 5px 18px 0 rgba(0, 0, 0, 0.16);
+  box-sizing: content-box;
+  .triangle {
+    position: absolute;
+    top: -0.56rem;
+    left: 14%;
+    width: 0;
+    height: 0;
+    transform: translate(-50%, -50%);
+    border: 0.64rem solid transparent;
+    border-bottom-color: #fff;
+  }
+  .emoji-content {
+    height: 100%;
+    overflow: auto;
+    margin-bottom: 10px;
+    .category {
+      max-width: 19rem;
+      max-height: 13rem;
+      display: flex;
+      flex-wrap: wrap;
+      align-items: center;
+      justify-content: flex-start;
+      overflow: hidden;
+      .item {
+        width: calc(19rem / 9);
+        height: calc(13rem / 6);
+        font-size: 1.25rem;
+        text-align: center;
+        line-height: calc(10rem / 6);
+        cursor: pointer;
+        &:hover {
+          font-size: 1.6rem;
+        }
+      }
+    }
+  }
+  .next-page {
+    list-style: none;
+    position: absolute;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    bottom: 10px;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    div {
+      list-style: none;
+      margin: 0 5px;
+      width: 6px;
+      height: 6px;
+      border-radius: 100%;
+      background-color: #f0f0f0;
+      cursor: pointer;
+      &.active {
+        cursor: default;
+        background-color: #d8d8d8;
+      }
+    }
+  }
+}
+</style>

+ 11 - 0
src/components/comment/index.js

@@ -0,0 +1,11 @@
+import JuejinComment from './index.vue'
+
+JuejinComment.install = function (Vue) {
+  Vue.component(JuejinComment.name, JuejinComment)
+}
+
+if (typeof window !== 'undefined' && window.Vue) {
+  JuejinComment.install(window.Vue)
+}
+
+export default JuejinComment

+ 546 - 0
src/components/comment/index.vue

@@ -0,0 +1,546 @@
+<template>
+  <div id="comment" ref="comment">
+    <!-- 顶部评论表单 -->
+    <comment-form :upload-img="uploadImg" @form-submit="formSubmit">
+      <img
+        class="avatar"
+        :src="user.avatar || ''"
+        @error="(e) => e.target.classList.add('error')"
+      >
+    </comment-form>
+
+    <!-- 底部评论列表 -->
+    <comment-list v-if="cacheData.length > 0" ref="comment-list">
+      <!-- 单条评论 -->
+      <comment-item
+        v-for="(comment, i) in cacheData"
+        :id="`comment-${i}`"
+        :key="`comment-${i}`"
+        :ref="`comment-${i}`"
+        :user="user"
+        :comment="comment"
+        @comment-reply="hasForm"
+        @comment-like="handleCommentLike"
+        @comment-delete="handleCommentDelete"
+      >
+        <!-- 回复表单 -->
+        <template #default="{ id }">
+          <comment-form
+            v-if="forms.includes(id)"
+            :id="id"
+            :parent="comment"
+            :placeholder="`回复${comment.user.name}...`"
+            :upload-img="uploadImg"
+            @form-submit="formSubmit"
+            @form-delete="deleteForm"
+          />
+        </template>
+
+        <!-- 单条评论下的回复列表 -->
+        <template #subList="{ parentId }">
+          <div>
+            <comment-list sub>
+              <!-- 单条回复 -->
+              <comment-item
+                v-for="(child, j) in comment.children"
+                :id="`${parentId}-${j}`"
+                :key="`${parentId}-${j}`"
+                :ref="`${parentId}-${j}`"
+                :comment="child"
+                :user="user"
+                :parent="comment"
+                @comment-reply="hasForm"
+                @comment-like="handleCommentLike"
+                @comment-delete="handleCommentDelete"
+              >
+                <!-- 单条回复的回复表单 -->
+                <comment-form
+                  v-if="forms.includes(`${parentId}-${j}`)"
+                  :id="`${parentId}-${j}`"
+                  :comment="child"
+                  :parent="comment"
+                  :placeholder="`回复${child.user && child.user.name}...`"
+                  :upload-img="uploadImg"
+                  @form-delete="deleteForm"
+                  @form-submit="formSubmit"
+                />
+              </comment-item>
+            </comment-list>
+            <el-pagination
+              v-model="page"
+              :length="length"
+              :total-visible="7"
+              @input="pageChange"
+            />
+          </div>
+        </template>
+      </comment-item>
+    </comment-list>
+  </div>
+</template>
+
+<script>
+import CommentForm from './components/CommentForm'
+import CommentList from './components/CommentList'
+import CommentItem from './components/CommentItem'
+import { childComment } from '@/api/comment'
+
+export default {
+  name: 'JuejinComment',
+  components: { CommentList, CommentItem, CommentForm },
+  inheritAttrs: false,
+  // 接收父组件通过 v-model 绑定的值
+  model: {
+    prop: 'videoComments',
+    event: 'input'
+  },
+  props: {
+    /* 数据 */
+    // model 中的 videoComments prop
+    videoComments: {
+      type: Array,
+      default: () => [],
+      required: true
+    },
+    /* 当前用户 */
+    user: {
+      type: Object,
+      default: () => {},
+      required: true
+    },
+    /* 配置属性 */
+    props: {
+      type: Object,
+      default: () => {}
+    },
+    /* 提交表单前事件 */
+    beforeSubmit: {
+      type: Function,
+      required: true
+    },
+    /* 执行点赞前事件 */
+    beforeLike: {
+      type: Function,
+      required: true
+    },
+    /* 执行删除前事件 */
+    beforeDelete: {
+      type: Function,
+      required: true
+    },
+    /* 上传图片 */
+    uploadImg: {
+      type: Function,
+      required: true
+    }
+  },
+  data() {
+    return {
+      forms: [], // 显示在视图上的所有表单 id
+      cacheData: [],
+      page: 1,
+      currentPage: 1,
+      length: 0
+    }
+  },
+  computed: {
+    computedProps({ props }) {
+      if (!props) return null
+      const entries = Object.entries(props)
+      return entries.length > 0 ? entries : null
+    }
+  },
+  watch: {
+    videoComments: {
+      immediate: true,
+      handler(value) {
+        // 数据发生变化时加载新数据
+        this.processVideoComments()
+      }
+    }
+  },
+  created() {
+    // 监听并执行一次
+    const cancel = this.$watch('data', () => {
+      this.processData()
+      cancel && cancel()
+    })
+  },
+  methods: {
+    /**
+     * 处理初始数据
+     */
+    processData() {
+      this.cacheData = this.data.map(this.comparePropsAndValues)
+    },
+    processVideoComments() {
+      this.cacheData = this.videoComments.map(this.comparePropsAndValues)
+      console.log(this.videoComments)
+    },
+    /** 对比和检查每条评论对象字段值 */
+    comparePropsAndValues(comment) {
+      // 初始对象
+      const originObj = {
+        id: '',
+        content: '',
+        imgSrc: '',
+        children: [],
+        likes: 0,
+        reply: null,
+        createAt: null,
+        user: {
+          userId: 10001,
+          name: '芒果',
+          avatar: '//oss.reghao.cn/image/a/41e5094eac724efeb2675eea22dd4468.jpg'
+        },
+        liked: false
+      }
+
+      // 赋值
+      for (const key in originObj) {
+        originObj[key] =
+          comment[this.props[key]] || comment[key] || originObj[key]
+
+        // 校验
+        this.validate({ key, value: originObj[key] })
+      }
+
+      if (originObj.children.length > 0) {
+        originObj.children = originObj.children.map(this.comparePropsAndValues)
+      }
+
+      return originObj
+    },
+
+    /** 校验数据 */
+    validate({ key, value }) {
+      const map = {
+        user: {
+          validate: function(v) {
+            return (
+              (typeof v !== 'object' || JSON.stringify(v) === '{}') &&
+              this.message
+            )
+          },
+          message: 'User must be an object with props.'
+        },
+        reply: {
+          validate: function(v) {
+            return typeof v !== 'object' && this.message
+          },
+          message: 'Reply must be an object'
+        },
+        children: {
+          validate: function(v) {
+            return !Array.isArray(v) && this.message
+          },
+          message: 'Children must be an array'
+        },
+        createAt: {
+          validate: function() {
+            return new Date(value).toString() === 'Invalid Date' && this.message
+          },
+          message: 'CreateAt is not a valid date.'
+        }
+      }
+
+      const target = map[key]
+      if (!target) return
+
+      const res = target.validate(value)
+      if (res) {
+        throw new Error(`validate(): ${res}`)
+      }
+    },
+
+    /**
+     * 将更新后的数组中的对象数据转换为初始对象结构
+     */
+    transformToOriginObj(comment) {
+      try {
+        const _comment = JSON.parse(JSON.stringify(comment))
+
+        if (_comment.children && _comment.children.length > 0) {
+          _comment.children = _comment.children.map(this.transformToOriginObj)
+        }
+
+        // 返回 props 中自定义的字段名
+        if (!this.computedProps) return _comment
+
+        for (const [key, value] of this.computedProps) {
+          if (key !== value && Object.hasOwnProperty.call(_comment, key)) {
+            _comment[value] = JSON.parse(JSON.stringify(_comment[key]))
+            delete _comment[key]
+          }
+        }
+
+        return _comment
+      } catch (e) {
+        console.error(e)
+      }
+    },
+
+    /**
+     * 判断是否已存在该id的表单,存在删除该表单,不存在则新增该表单,并触发其他表单blur事件
+     */
+    hasForm(id) {
+      this.forms.includes(id) ? this.deleteForm(id) : this.addForm(id)
+      this.broadcastBlur(this.$refs['comment-list'].$children, id)
+    },
+
+    /**
+     * 增加新表单
+     */
+    addForm(id) {
+      this.forms.push(id)
+      // this.scrollIntoView(`${id}-form`)
+    },
+
+    /** 删除表单 */
+    deleteForm(id) {
+      const index = this.forms.indexOf(id)
+      index > -1 && this.forms.splice(index, 1)
+    },
+
+    /**
+     * 评论或回复
+     */
+    async formSubmit({
+      newComment: { id, callback, ...params },
+      parent = null
+    }) {
+      const _params = Object.assign(params, { user: this.user })
+
+      // 等待外部提交事件执行
+      if (typeof this.beforeSubmit === 'function') {
+        try {
+          const data = this.transformToOriginObj(_params)
+
+          const add = (data) => {
+            this.addComment(id, this.comparePropsAndValues(data))
+            callback()
+          }
+
+          await this.beforeSubmit(data, parent, add)
+        } catch (e) {
+          console.error(e)
+        }
+      }
+    },
+
+    async handleCommentLike({ id, comment: { children, ...params }}) {
+      const _params = Object.assign(params, { user: this.user })
+      if (typeof this.beforeLike === 'function') {
+        try {
+          await this.beforeLike(this.transformToOriginObj(_params))
+
+          this.storeLikes(id)
+        } catch (e) {
+          console.error(e)
+        }
+      }
+    },
+
+    /**
+     * 删除评论或回复
+     */
+    async handleCommentDelete({ id, comment, parent = null }) {
+      if (typeof this.beforeDelete === 'function') {
+        try {
+          const data = this.transformToOriginObj(comment)
+          await this.beforeDelete(data, parent)
+
+          this.deleteComment(id)
+        } catch (e) {
+          console.error(e)
+        }
+      }
+    },
+
+    /**
+     * 保存点赞
+     */
+    storeLikes(id) {
+      const { commentIndex, replyIndex } = this.getIndex(id)
+
+      let comment = this.cacheData[commentIndex]
+
+      if (!isNaN(replyIndex)) {
+        comment = comment.children[replyIndex]
+      }
+
+      comment.liked = !comment.liked
+
+      if (comment.likes) {
+        comment.liked ? comment.likes++ : comment.likes--
+      } else {
+        comment.likes = 1
+      }
+
+      const data = this.cacheData.map(this.transformToOriginObj)
+      this.$emit('input', data)
+    },
+
+    /**
+     * 存储新评论或回复
+     */
+    addComment(id, rawData) {
+      const { commentIndex } = this.getIndex(id)
+
+      // 更新视图
+      if (commentIndex === 'root') {
+        this.cacheData.push(rawData)
+      } else {
+        const comment = this.cacheData[commentIndex]
+        comment.children.push(rawData)
+      }
+
+      // 滚动至可见视图上
+      const signal =
+        commentIndex === 'root'
+          ? this.cacheData.length - 1
+          : `${commentIndex}-${
+            this.cacheData[commentIndex].children.length - 1
+          }`
+      this.scrollIntoView(`comment-${signal}`)
+
+      // 更新外部数据
+      const data = this.cacheData.map(this.transformToOriginObj)
+      this.$emit('input', data)
+    },
+
+    /**
+     * 删除评论或回复
+     */
+    deleteComment(id) {
+      const { commentIndex, replyIndex } = this.getIndex(id)
+
+      this.cacheData = this.cacheData.filter((c, i) => {
+        if (isNaN(replyIndex)) {
+          return i !== commentIndex
+        } else {
+          c.children = c.children.filter((r, j) => j !== replyIndex)
+          return c
+        }
+      })
+
+      const data = this.cacheData.map(this.transformToOriginObj)
+      this.$emit('input', data)
+    },
+
+    /**
+     * 向下递归触发表单blur事件
+     */
+    broadcastBlur(target, id) {
+      if (id && target.id === id) return
+
+      if (Array.isArray(target)) {
+        target.map((c) => this.broadcastBlur(c, id))
+      } else {
+        const children = target.$children
+        children && this.broadcastBlur(children, id)
+
+        const richInput = target.$refs['rich-input']
+        richInput && richInput.blur()
+      }
+    },
+
+    /**
+     * 从id中提取出序号
+     */
+    getIndex(id) {
+      const [, c, r] = id.split('-')
+      return { commentIndex: c === 'root' ? c : +c, replyIndex: +r }
+    },
+
+    /**
+     * 将子组件滚动到视图可见区域
+     */
+    scrollIntoView(ref) {
+      this.$nextTick(() => {
+        this.$refs[ref][0].$el.scrollIntoView(false)
+      })
+    },
+    pageChange(page) {
+      if (page !== this.currentPage) {
+        this.currentPage = page
+        console.log('获取下一页子评论')
+      }
+    },
+    getChildComments(parentId) {
+      childComment(parentId, this.page).then(res => {
+        if (res.code === 0) {
+          this.page += 1
+        } else {
+          console.error(res.msg)
+        }
+      })
+        .catch(error => {
+          console.error(error.message)
+        })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+#comment {
+  // border-top: 1px solid #ebebeb;
+  padding-top: 1.0664rem;
+  & > .comment-form {
+    margin: 0 1.3328rem 1.0664rem;
+  }
+  & > .comment-list {
+    margin: 0 1.3328rem 0 5.2rem;
+    background-color: #fff;
+  }
+
+  ::v-deep {
+    img {
+      user-select: none;
+      -webkit-user-drag: none;
+      &.avatar {
+        width: 2.1336rem;
+        height: 2.1336rem;
+        border-radius: 50%;
+        cursor: pointer;
+      }
+      &.error {
+        display: inline-block;
+        transform: scale(0.5);
+        content: '';
+        color: transparent;
+        &::before {
+          content: '';
+          position: absolute;
+          left: 0;
+          top: 0;
+          width: 100%;
+          height: 100%;
+          border-radius: 50%;
+          border: 1px solid #e7e7e7;
+          box-sizing: border-box;
+          transform: scale(2);
+          background: #f5f5f5
+            url("data:image/svg+xml,%3Csvg class='icon' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cpath d='M304.128 456.192c48.64 0 88.064-39.424 88.064-88.064s-39.424-88.064-88.064-88.064-88.064 39.424-88.064 88.064 39.424 88.064 88.064 88.064zm0-116.224c15.36 0 28.16 12.288 28.16 28.16s-12.288 28.16-28.16 28.16-28.16-12.288-28.16-28.16 12.288-28.16 28.16-28.16z' fill='%23e6e6e6'/%3E%3Cpath d='M887.296 159.744H136.704C96.768 159.744 64 192 64 232.448v559.104c0 39.936 32.256 72.704 72.704 72.704h198.144L500.224 688.64l-36.352-222.72 162.304-130.56-61.44 143.872 92.672 214.016-105.472 171.008h335.36C927.232 864.256 960 832 960 791.552V232.448c0-39.936-32.256-72.704-72.704-72.704zm-138.752 71.68v.512H857.6c16.384 0 30.208 13.312 30.208 30.208v399.872L673.28 408.064l75.264-176.64zM304.64 792.064H165.888c-16.384 0-30.208-13.312-30.208-30.208v-9.728l138.752-164.352 104.96 124.416-74.752 79.872zm81.92-355.84l37.376 228.864-.512.512-142.848-169.984c-3.072-3.584-9.216-3.584-12.288 0L135.68 652.8V262.144c0-16.384 13.312-30.208 30.208-30.208h474.624L386.56 436.224zm501.248 325.632c0 16.896-13.312 30.208-29.696 30.208H680.96l57.344-93.184-87.552-202.24 7.168-7.68 229.888 272.896z' fill='%23e6e6e6'/%3E%3C/svg%3E")
+            no-repeat center / 50% 50%;
+        }
+      }
+    }
+  }
+}
+
+@media screen and (max-width: 600px) {
+  #comment {
+    & > .comment-list {
+      margin: 0 1.6rem;
+    }
+    & > .comment-form {
+      margin: 1rem 1.6rem;
+    }
+    & > ::v-deep .comment-root .avatar-box {
+      display: none;
+    }
+  }
+}
+</style>

+ 1 - 2
src/views/home/LivePage.vue

@@ -123,13 +123,12 @@
 
 <script>
 import LivePlayer from 'components/LivePlayer'
-import Comment from 'components/comment/Comment'
 import VideoCard from 'components/card/VideoCard'
 import UserAvatarCard from '@/components/card/UserAvatarCard'
 
 export default {
   name: 'LivePage',
-  components: { Comment, VideoCard, LivePlayer, UserAvatarCard },
+  components: { VideoCard, LivePlayer, UserAvatarCard },
   data() {
     return {
       video: {

+ 83 - 3
src/views/home/VideoPage.vue

@@ -107,7 +107,17 @@
             </el-row>
           </div>
           <div class="text item">
-            <comment :video-id="video.videoId" />
+            <div ref="comment" :style="wrapStyle" class="comment-wrap">
+              <comment
+                v-model="videoComments"
+                :user="currentUser"
+                :props="props"
+                :before-submit="submit"
+                :before-like="like"
+                :before-delete="deleteComment"
+                :upload-img="uploadImg"
+              />
+            </div>
           </div>
         </el-card>
       </el-row>
@@ -230,9 +240,9 @@
 <script>
 import PermissionDeniedCard from '@/components/card/PermissionDeniedCard'
 import VideoPlayer from 'components/VideoPlayer'
-import Comment from 'components/comment/Comment'
 import VideoCard from 'components/card/VideoCard'
 import UserAvatarCard from '@/components/card/UserAvatarCard'
+import comment from '@/components/comment'
 
 import { similarVideo, videoInfo, videoErrorReport, downloadVideo, cacheBiliVideo } from '@/api/video'
 import { collectItem } from '@/api/collect'
@@ -241,9 +251,31 @@ import { submitAccessCode } from '@/api/content'
 
 export default {
   name: 'VideoPage',
-  components: { Comment, VideoCard, VideoPlayer, UserAvatarCard, PermissionDeniedCard },
+  components: { VideoCard, VideoPlayer, UserAvatarCard, PermissionDeniedCard, comment },
   data() {
     return {
+      /** ********************************************************************/
+      wrapStyle: '',
+      videoComments: [],
+      currentUser: {
+        name: '草莓',
+        avatar: '//picx.zhimg.com/v2-a2c89378a6332cbfed3e28b5ab84feb7.jpg'
+      },
+      props: {
+        id: 114511,
+        content: 'this is comment content',
+        imgSrc: '//oss.reghao.cn/image/a/41e5094eac724efeb2675eea22dd4468.jpg',
+        children: [],
+        likes: 0,
+        liked: false,
+        reply: null,
+        createAt: 'createAt',
+        user: {
+          name: '芒果',
+          avatar: '//oss.reghao.cn/image/a/41e5094eac724efeb2675eea22dd4468.jpg'
+        }
+      },
+      /** ********************************************************************/
       video: null,
       videoProp: {
         info: null,
@@ -285,6 +317,10 @@ export default {
     this.getVideoInfo(videoId)
     this.getSimilarVideos(videoId)
   },
+  mounted() {
+    const header = this.$refs.header
+    this.wrapStyle = `height: calc(100vh - ${header.clientHeight + 20}px)`
+  },
   methods: {
     // 获取视频的详细信息
     getVideoInfo(videoId) {
@@ -470,6 +506,50 @@ export default {
           duration: 3000
         })
       })
+    },
+    async submit(newComment, parent, add) {
+      const res = await new Promise((resolve) => {
+        setTimeout(() => {
+          resolve({ newComment, parent })
+        }, 300)
+      })
+      add(Object.assign(res.newComment, { _id: new Date().getTime() }))
+      console.log('addComment: ', res)
+    },
+    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)
     }
   }
 }