|
|
@@ -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>
|