使用Vue开发项目(黑马头条项目)--第七天

642 阅读4分钟

需要实现的主要功能如下:

资讯列表、标签页切换,文章举报,频道管理、文章详情、阅读记忆,关注功能、点赞功能、评论功能、回复评论、搜索功能、登录功能、个人中心、编辑资料、小智同学 ...

今天要实现的功能主要是:文章详情,关注功能,点赞功能,评论功能

1 文章详情功能

1.1 创建views/article/article.vue 并写入以下内容

<template>
  <div class="article-container">
    <!-- 导航栏 -->
    <van-nav-bar
      fixed
      left-arrow
      @click-left="$router.back()"
      title="文章详情"
    ></van-nav-bar>
    <!-- /导航栏 -->

    <!-- 加载中 loading -->
    <van-loading class="article-loading" />
    <!-- /加载中 loading -->

    <!-- 文章详情 -->
    <div class="detail">
      <h3 class="title">标题</h3>
      <div class="author">
        <van-image round width="1rem" height="1rem" fit="fill" />
        <div class="text">
          <p class="name">作者</p>
          <p class="time">4天前</p>
        </div>
        <van-button
          round
          size="small"
          type="info"
        >+ 关注</van-button>
      </div>
      <div class="content">
        <div>正文</div>
      </div>
      <van-divider>END</van-divider>
      <div class="zan">
        <van-button round size="small" hairline type="primary" plain icon="good-job-o">点赞</van-button>
        &nbsp;&nbsp;&nbsp;&nbsp;
        <van-button round size="small" hairline type="danger" plain icon="delete">不喜欢</van-button>
      </div>
    </div>
    <!-- /文章详情 -->

  </div>
</template>

<script>
export default {
  name: 'ArticleIndex',
  data () {
    return {
      loading: true, // 控制加载中的 loading 状态
      article: { }
    }
  }
}
</script>

<style scoped lang='less'>
.article-container{
  position: absolute;
  left: 0;
  top: 0;
  overflow-y: scroll;
  width: 100%;
  height: 100%;
}
.article-loading {
  padding-top: 100px;
  text-align: center;
}
.error{
  padding-top: 100px;
  text-align: center;
}
.detail {
  padding: 50px 10px;
  .title {
    font-size: 16px;
  }
  .zan{
    text-align: center;
  }
  .author {
    padding: 10px 0;
    display: flex;
    .text {
      flex: 1;
      padding-left: 10px;
      line-height: 1.3;
      .name {
        font-size: 14px;
        margin: 0;
      }
      .time {
        margin: 0;
        font-size: 12px;
        color: #999;
      }
    }
  }
  .content {
    font-size:14px;
    overflow: hidden;
    white-space: pre-wrap;
    word-break: break-all;
    /deep/ img{
      max-width:100%;
      background: #f9f9f9;
    }
  }
}
</style>

1.2路由配置

  {
    path: '/article/:id', // 动态路由
    name: 'article',
    component: () => import('../views/article/article.vue')
  },

1.3 测试效果

image.png

1.4 文章详情-路由跳转

views/home/articleList.vue中,点击文章列表项的时候,传递文章id跳转到文章详情页

<van-cell
   v-for="(item,idx) in list"
   :key="idx"
   :title="item.title"
+  @click="$router.push('/article/' + item.art_id)"
>

1.5 文章详情-获取数据并显示

1.5.1 封装接口

api/article.js 中新增一个方法

/**
 * 获取文章详情
 * @param {*} articleId
 */
export const getDetail = articleId => {
  return request({
    method: 'GET',
    url: 'v1_0/articles/' + articleId
  })
}

1.5.2 调用接口

views/article/article.vue组件中调用接口,获取文章详情

created () {
    this.loadDetail()
  },
  methods: {
    async loadDetail () {
      try {
        this.loading = true
        const res = await getDetail(this.$route.params.id)
        this.article = res.data.data
        this.loading = false
      } catch (err) {
        this.loading = false
        console.log(err)
      }
    }
  }

1.5.3 更改模板,渲染页面

<!-- 导航栏 -->
    <van-nav-bar
      fixed
      left-arrow
      @click-left="$router.back()"
      :title="'文章详情-'+ article.title"
    ></van-nav-bar>
    <!-- /导航栏 -->

    <!-- 加载中 loading -->
    <van-loading v-if="loading" class="article-loading" />
    <!-- /加载中 loading -->

    <!-- 文章详情 -->
    <div class="detail">
      <h3 class="title">{{article.title}}</h3>
      <div class="author">
        <van-image round width="1rem" height="1rem" fit="fill" />
        <div class="text">
          <p class="name">{{article.aut_name}}</p>
          <p class="time">{{article.pubdate | relativeTime}}</p>
        </div>
        <van-button
          round
          size="small"
          type="info"
        >+ 关注</van-button>
      </div>
      <div class="content">
        <div v-html="article.content"></div>
      </div>
      <van-divider>END</van-divider>
      <div class="zan">
        <van-button round size="small" hairline type="primary" plain icon="good-job-o">点赞</van-button>
      </div>
    </div>
    <!-- /文章详情 -->

注意:

  • 文章正文是html格式字符串,需要用v-html才能正确显示
  • 相对时间处理:直接使用我们在前面定义的全局过滤器即可。

1.5.4 查看效果

image.png

2 文章操作-关注&取关

  • 如果is_followed为 false 表示当前登陆用户并没有关注过本文章的作者。
  • 如果is_followed为true 表示当前登陆用户已经关注过本文章的作者

2.1封装接口

api/user.js 中新增两个方法:

// 关注
export const follow = userId => {
  return request({
    url: '/v1_0/user/followings', // 接口地址
    method: 'POST', // 方式
    data: {
      target: userId
    }
  })
}

// 取关
export const unfollow = userId => {
  return request({
    url: '/v1_0/user/followings/' + userId, // 接口地址
    method: 'DELETE' // 方式
  })
}

2.2调用接口

\views\article\article.vue模板中

<van-button
          round
          size="small"
          type="info"
+         @click="toggleFollow"
+        >{{article.is_followed ? '取关' : '+ 关注'}}</van-button>
async toggleFollow () {
      try {
        // 检查当前的状态
        const isFollowed = this.article.is_followed
        const userId = this.article.aut_id
        console.log(isFollowed)
        if (isFollowed) {
          //   取关
          await unfollow(userId)
        } else {
          await follow(userId)
        }
        // 更新视图
        //  1. 整体重发请求?(没有必要)
        //  2. 直接修改本地数据is_followed
        this.article.is_followed = !isFollowed
        this.$toast.success('操作成功')
      } catch (err) {
        console.log(err)
        this.$toast.fail('操作失败')
      }
    }

2.3 效果图

image.png

3 文章操作-点赞&取消点赞

从后端取回来的文章详情中有一个attitude属性用来描述用户对文章的态度,具体是:{-1:无态度,0:不喜欢,1:点赞}

如果是做点赞,就是把attitude改成1 。取消点赞,变成无态度,就是把 attiude改成 -1。

对应的,视图上有两个地方要修改:

  • 文案
  • 图标

3.1 封装接口

api/article.js 中封装数据接口

/**
 * 取消点赞
 * @param {*} id 文章编号
 */
export const deleteLike = id => {
  return request({
    method: 'DELETE',
    url: 'v1_0/article/likings/' + id
  })
}

/**
 * 添加点赞
 * @param {*} id 文章编号
 */
export const addLike = id => {
  return request({
    method: 'POST',
    url: 'v1_0/article/likings',
    data: {
      target: id
    }
  })
}

3.2 调用接口

然后在 views/article/article.vue 组件中

<template>

    <van-button round size="small"
        hairline
        type="primary"
        plain
        :icon="article.attitude === 1 ? 'good-job': 'good-job-o'"
        @click="toggleLike">{{ article.attitude == 1 ? '取消点赞' : '点赞' }}     </van-button>
      
</template>

<script>
import { addLike, deleteLike } from '@/api/article'

export default {
  // ...
  methods: {
     async toggleLike () {
      try {
        // 检查当前的状态
        const attitude = this.article.attitude
        const artId = this.article.art_id
        if (attitude === 1) {
          await deleteLike(artId)
          this.article.attitude = -1
        } else if (attitude === -1) {
          await addLike(artId)
          this.article.attitude = 1
        }
        this.$toast.success('操作成功')
      } catch (err) {
        console.log(err)
        this.$toast.fail('操作失败')
      }
    }
}
</script>

3.3 效果图

image.png

4 处理404

4.1views/article/article.vue 组件中

return {
+     is404: false, // 文章是否存在
      loading: true, // 控制加载中的 loading 状态
      article: { } // 当前文章
    }
async loadDetail () {
  try {
    this.is404 = false

    this.loading = true
    const {data:{data}} = await getDetail(this.$route.params.id)
    this.article = data
    this.loading = false
  } catch (err) {
    this.loading = false
    console.dir(err)
    // 如何去判断本次请求是404?
    if (err.response.status === 404) {
      this.is404 = true
    }
  }
}
 <!-- 加载中 loading -->
    <van-loading v-if="loading" class="article-loading" />
    <!-- 加载中 loading -->

    <div class="error" v-if="is404">
      <p>文章被外星人吃掉了</p>
      <van-button @click="$router.back()">后退</van-button>
      <van-button @click="$router.push('/')">回主页</van-button>
    </div>
    <!-- 文章详情 -->
    <div class="detail" v-else>
       // 省略 -----
    </div>

效果图

image.png

5 文章评论

5.1 基本布局

article/comment.vue 添加一个组件来完成评论列表功能

<template>
  <div class="article-comments">
    <!-- 评论列表 -->
    <van-list
      v-model="loading"
      :finished="finished"
      finished-text="没有更多了"
      @load="onLoad"
    >
      <van-cell
        v-for="item in list"
        :key="item.com_id"
        >
        <van-image
          slot="icon"
          round
          width="30"
          height="30"
          style="margin-right: 10px;"
          :src="item.aut_photo"
        />
        <span style="color: #466b9d;" slot="title">{{item.aut_name}}</span>
        <div slot="label">
          <p style="color: #363636;">{{item.content}}</p>
          <p>
            <span style="margin-right: 10px;">{{item.pubdate | relativeTime }}</span>
          </p>
        </div>
        <van-icon slot="right-icon" name="like-o" />
      </van-cell>
    </van-list>
    <!-- 评论列表 -->
    <!-- 发布评论 -->
    <div :class="commentShow ? 'art-cmt-container-1' : 'art-cmt-container-2'">
      <!-- 底部添加评论区域 - 1 -->
      <div class="add-cmt-box van-hairline--top">
        <van-icon name="arrow-left" size="24px" @click="$router.back()" />
        <div class="ipt-cmt-div">发表评论</div>
        <div class="icon-box">
          <van-badge content="10" max="99">
            <van-icon
              name="comment-o"
              size="24px"
            />
          </van-badge>
          <van-icon name="star-o"  size="24px" />
          <van-icon name="share-o" size="24px" />
        </div>
      </div>

      <!-- 底部添加评论区域 - 2 -->
      <div class="cmt-box van-hairline--top" v-show="!commentShow">
        <textarea
          placeholder="友善评论、理性发言、阳光心灵"
          v-model.trim="commentText"
        ></textarea>
        <van-button
          type="default"
          >发布</van-button>
      </div>
    </div>
    <!-- /发布评论 -->
  </div>
</template>

<script>
export default {
  name: 'ArticleComment',
  data () {
    return {
      commentText: '',
      commentShow: true,
      list: [], // 评论列表
      loading: false, // 上拉加载更多的 loading
      finished: false // 是否加载结束
    }
  },

  methods: {
    onLoad () {
      // 异步更新数据
      // setTimeout 仅做示例,真实场景中一般为 ajax 请求
      setTimeout(() => {
        for (let i = 0; i < 10; i++) {
          this.list.push(this.list.length + 1)
        }

        // 加载状态结束
        this.loading = false

        // 数据全部加载完成
        if (this.list.length >= 40) {
          this.finished = true
        }
      }, 1000)
    }
  }
}
</script>

<style scoped lang='less'>
// 发表评论的区域是固定在下端的
.publish-wrap {
  position: fixed;
  left: 0;
  bottom: 0;
  width: 100%;
}
// 给发表评论区空出地方
.van-list {
  margin-bottom: 45px;
}

/*美化 - 文章详情 - 底部的发布评论-样式 */
// 外层容器
.art-cmt-container-1 {
  padding-bottom: 46px;
}
.art-cmt-container-2 {
  padding-bottom: 80px;
}

// 发布评论的盒子 - 1
.add-cmt-box {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  box-sizing: border-box;
  background-color: white;
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 46px;
  line-height: 46px;
  padding-left: 10px;
  .ipt-cmt-div {
    flex: 1;
    border: 1px solid #efefef;
    border-radius: 15px;
    height: 30px;
    font-size: 12px;
    line-height: 30px;
    padding-left: 15px;
    margin-left: 10px;
    background-color: #f8f8f8;
  }
  .icon-box {
    width: 40%;
    display: flex;
    justify-content: space-evenly;
    line-height: 0;
  }
}

.child {
  width: 20px;
  height: 20px;
  background: #f2f3f5;
}

// 发布评论的盒子 - 2
.cmt-box {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 80px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 12px;
  padding-left: 10px;
  box-sizing: border-box;
  background-color: white;
  textarea {
    flex: 1;
    height: 66%;
    border: 1px solid #efefef;
    background-color: #f8f8f8;
    resize: none;
    border-radius: 6px;
    padding: 5px;
  }
  .van-button {
    height: 100%;
    border: none;
  }
}
</style>

5.2 注册并引入

在文章详情页面src\views\article\article.vue中加载注册文章评论子组件:

import ArticleComment from './comment'

export default {
  ...
  components: {
    ArticleComment
  }
}
<div class="article-container">
	...  	
    <!-- 文章评论 -->
    <article-comment></article-comment>
    <!-- 文章评论 -->
</div>

效果图

image.png

5.3获取文章评论数据并显示

api/comment.js 中封装请求方法

/**
 * 获取评论
 * @param {*} articleId
 * @param {*} offset
 */
export const getComment = (articleId, offset) => {
  return request({
    method: 'GET',
    url: '/v1_0/comments',
    params: {
      type: 'a',
      source: articleId,
      offset
    }
  })
}

5.4 加载获取数据

views/article/comment.vue 组件中加载获取数据

// 数据项
data () {
  return {
+   total_count: 10,
+   offset: null, // 获取评论数据的偏移量,值为评论id,表示从此id的数据向后取,不传表示从第一页开始读取数据
  }
}
import { getComments } from '@/api/comment'
async onLoad () {
  try {
      // 1. 发请求
      const {data:{data}} = await getComments(this.$route.params.id, this.offset)
      const arr = data.results
      // 2. 追加到list
      this.list.push(...arr)
      // 3. loading <-false
      this.loading = false
      // 4. 判断是否加载完成
      this.finished = !arr.length
      // 5. 更新offset
      this.offset = data.last_id
      // 6. 总数
      this.total_count = data.total_count
  } catch (error) {
    this.$toast('获取评论失败')
    this.loading = false
  }
}

模板绑定

<van-cell
        v-for="item in list"
        :key="item.com_id"
        >
        <van-image
          slot="icon"
          round
          width="30"
          height="30"
          style="margin-right: 10px;"
          :src="item.aut_photo"
        />
        <span style="color: #466b9d;" slot="title">{{item.aut_name}}</span>
        <div slot="label">
          <p style="color: #363636;">{{item.content}}</p>
          <p>
            <span style="margin-right: 10px;">{{item.pubdate | relativeTime }}</span>
            <van-button size="mini" type="default">回复</van-button>
          </p>
        </div>
        <van-icon slot="right-icon" name="like-o" />
      </van-cell>

效果图

image.png

6 发布文章评论-基本交互

image.png

用boolean来控制两个状态的切换

(1)点击发表评论:从状态1---> 状态2

(2)输入框失去焦点时,状态2 ----> 状态1

6.1 视图

<!-- 底部添加评论区域 - 1 -->
<div class="add-cmt-box van-hairline--top" v-show="commentShow">
<!-- 底部添加评论区域 - 2 -->     
<div class="cmt-box van-hairline--top" v-show="!commentShow">

6.2 添加事件及属性

<!-- 发布评论 -->
    <div :class="commentShow ? 'art-cmt-container-1' : 'art-cmt-container-2'">
      <!-- 底部添加评论区域 - 1 -->
  +   <div class="add-cmt-box van-hairline--top" v-show="commentShow">
        <van-icon name="arrow-left" size="24px" @click="$router.back()" />
  +     <div class="ipt-cmt-div" @click="hShowCommentArea">发表评论</div>
        <div class="icon-box">
          <van-badge content="10" max="99">
            <van-icon name="comment-o" size="24px" />
          </van-badge>
          <van-icon name="star-o" size="24px" />
          <van-icon name="share-o" size="24px" />
        </div>
      </div>

      <!-- 底部添加评论区域 - 2 -->
      <div class="cmt-box van-hairline--top" v-show="!commentShow">
        <textarea
 +        ref="txt"
 +        @blur="hBlur"
          placeholder="友善评论、理性发言、阳光心灵"
          v-model.trim="commentText"
        ></textarea>
 +      <van-button type="default" @click="hAddComment">发布</van-button>
      </div>
    </div>
    <!-- /发布评论 -->

6.3 执行代码

// 用户点击添加
    hAddComment () {
      alert('hAddComment')
    },
    // 输入框失去焦点
    hBlur () {
      // 回到状态1
      // this.$nextTick(() => {
      //   this.commentShow = true
      // })
      setTimeout(() => {
        this.commentShow = true
      })
    },
    // 用户点击了发表评论
    hShowCommentArea () {
      // 状态2会显示出来
      this.commentShow = false // 通知视图 等会 去更新
      // $nextTick(回调函数)  // 更新视图之后,去执行回调函数
      this.$nextTick(() => {
        // 让输入框获取焦点
        this.$refs.txt.focus()
      })
    }

6.4 发布文章评论

api/comment.js 中添加封装数据接口

import request from '@/utils/request.js'

/**
 * 添加文章评论
 * @param {*} articleId
 * @param {*} content
 */
export const addComment = (articleId, content) => {
  return request({
    method: 'POST',
    url: 'v1_0/comments',
    data: {
      target: articleId,
      content
    }
  })
}

comment.vue 组件中,hAddComment

// 用户点击添加
    async hAddComment () {
      if (!this.commentText) {
        return
      }

      try {
        const {data:{data}} = await addComment(this.$route.params.id, this.commentText)
        this.$toast.success('添加评论成功')

        // data.new_obj // 当前评论数据
        this.list.unshift(data.new_obj)

        // 更新页面
      } catch (err) {
        console.log(err)
        this.$toast.fail('添加评论失败')
      }
    },

6.5 实现发布评论===>滚动条滚动到发布评论位置

在comment.vue最上层加入一个id="scrollTh"的div

<div id="scrollTh"></div>
 // 用户点击添加
    async hAddComment () {
      if (!this.commentText) {
        return
      }
      try {
        const res = await addComment(this.$route.params.id, this.commentText)
        console.log(res)
        this.$toast.success('添加评论成功')
        this.list.unshift(res.data.data.new_obj)
        // 滚动条滚动到评论发布的位置
+       this.$el.querySelector('#scrollTh').scrollIntoView({
          // top: 0,
          // el: '#scrollTh',
+         behavior: 'smooth', // 平滑过渡
+         block: 'start' // 上边框与视窗顶部平齐。默认值
+       })
        // 更新页面
      } catch (err) {
        console.log(err)
        this.$toast.fail('添加评论失败')
      }
    },

7 文章评论-点赞

在后端接口取出回来的数据中,有一个特殊的字段is_liking表示当前用户对当前评论是否喜欢。

  1. 从接口中取出数据之后,根据is_liking这个字段来来更新视图
  2. 用户点击之后,调用接口去修改is_liking这个字段在服务器上的值,并更新视图。

7.1 封装接口

api/comment.js 中添加两个方法

/**
 * 对文章评论进行点赞
 * @param {*} commentId 评论id
 */
export const addCommentLike = commentId => {
  return request({
    method: 'POST',
    url: 'v1_0/comment/likings',
    data: {
      target: commentId
    }
  })
}

/**
 * 取消文章评论的点赞
 * @param {*} commentId 评论id
 */
export const deleteCommentLike = commentId => {
  return request({
    method: 'DELETE',
    url: 'v1_0/comment/likings/' + commentId
  })
}

7.2 修改视图

views/article/comment.vue 组件中:

<van-icon
       slot="right-icon"
       :name="item.is_liking ? 'like' : 'like-o'"
       @click="hToggleLike(item)"
/>

7.3 具体实现代码

<script>
import {
  getComments,
  addComment,
+  addCommentLike,
+  deleteCommentLike
} from '@/api/comment'

export default {
  methods: {
    // 用户喜欢/取消喜欢评论
    async hToggleLike (item) {
      try {
        const isLiking = item.is_liking
        const commentId = item.com_id
        if (isLiking) {
          await deleteCommentLike(commentId)
        } else {
          await addCommentLike(commentId)
        }
        this.$toast.success('操作成功')

        // 更新本地数据
        item.is_liking = !isLiking
      } catch (err) {
        console.log(err)
        this.$toast.fail('操作失败')
      }
    }
  }
}
</script>

效果图

image.png