# 极客园M端
# 第一章:项目起步
# 01-项目介绍
目标:了解项目背景,了解项目功能。
项目背景:
- 它对标 CSDN 博客园 等竞品,致力成为全球知名的IT技术交流平台,它包含 技术文章,问答内容,视频解答 的专业IT资讯平台,它提供原创,优质,完整内容的专业IT社区。它是 
极客园IT资讯社区。 
项目功能:
极客园-个人端M是一款移动web应用。- 主要功能有:
- 首页-文章频道,文章列表,更多操作
 - 详情-文章详情,文章评论,评论回复,点赞,收藏,关注
 - 登录-短信登录
 - 个人-信息展示,信息编辑
 
 
项目物料:http://geek.itheima.net
总结: 我们知道项目的大致功能即可。
# 02-使用技术
目标:了解使用技术
开发依赖大致如下:
- 基础环境:
nodejs12+vscodevuecli4.x - 配套工具:
eslintbabelless - 使用技术:
vue2.6.12vue-routervue-vuexvanticonfontdayjssocket.io-clientpostcss-px-to-viewport 
项目中的解决方案:
- 使用vue-cli创建vue单页应用解决方案
 - 使用vue-router实现前端路由解决方案
 - 使用vue-vuex实现状态管理解决方案
 - 使用vant快速搭建移动界面解决方案
 - 使用json-bigint处理最大安全整数解决方案
 - 使用iconfont实现前端多色字体图标解决方案
 - 使用dayjs处理相对时间计算解决方案
 - 使用soket.io实现即时通讯解决方案
 - 使用postcss-px-to-viewport 实现移动端适配解决方
 
总结: 我们大概知道用了那些东西即可。
# 03-创建项目
目标:知道如何使用vue-cli创建项目
大致步骤:
- 在某个目录打开命令行工具输入创建项目的命令
 - 安装项目需求选择具体的工具,然后等待创建吧
 - 最后进入创建好的项目,启动项目即可
 
具体如下:
- 执行创建命令
 
vue create geek-client-mobile
- 选中自定义创建
 
- 选择Vue版本,依赖Babel降级ES6语法,依赖vue-router,依赖vuex,使用css预处理器,使用代码风格校验。
 
- 选择vue2.0版本
 
- 是否使用历史模式API,输入 n
 
- 选择less这种css预处理器
 
- 选择 通用语法风格配置
 
- 语法风格校验的时机,保存代码校验,提交代码校验且自动修复。
 

- 选择使用不同的配置文件对于所依赖工具
 

- 是否记录此次操作记录,输入 n
 
- 最后等待安装即可,安装完毕进入项目目录,执行 
npm run serve即可启动项目。 
总结: 我们可以使用vuecli根据自己项目需求创建合适的项目。
# 04-调整目录
目标:根据项目功能调整下目录结构
大致步骤:
- 配置文件解释说明
 - 调整src下目录结构
 
落地内容:
- 根目录和配置文件。都是自动生成的,了解作用即可
 
├─node_modules
├─public
├─src
├─.browserslistrc     # 适配浏览器列表
├─.editorconfig       # 提供给编辑器的配置
├─.eslintrc.js        # eslint代码风格配置
├─.gitignore          # git忽略文件配置
├─.babel.config.js    # babelES降级配置
├─package-lock.json   # 包下载版本说明文件
├─package.json        # 项目包说明文件
├─postcss.config.js   # postcss,css预处理器后处理器配置
├─README.md           # 说明MD文件
└─vue.config.js       # vue-cli的配置文件
- src 目录结构如下,仅供参考 (分模块的思维才重要)
 
├─api              # 接口函数
├─assets           # 项目资源
│  ├─images          # 图片 
│  └─styles          # less代码
├─components       # 全局组件,通用组件
├─router           # 路由
├─store            # 状态
├─utils            # 工具
└─views            # 路由组件(页面)
    ├─article        # 文章详情
    ├─home           # 首页
    ├─question       # 问答
    ├─user           # 用户模块
    └─video          # 视频
总结: 做好开发前的准备,调整下项目结构。
# 第二章:项目架构
# 01-引入vant
目的:在项目中引入Vant组件库
大致步骤:
- 从官方了解引入Vant的几种方式 	引入vant方式 (opens new window) 
- 自动按需(推荐)- 打包体积小
 - 手动按需
 - 全部引入
 
 - 我们采用 全部引入 方式
- 开发过程中使用方便,一次引入全局使用。
 - 后续做打包优化可以降低打包体积。
 
 
落地代码:
- 安装
 
npm i vant
- 引入 
main.js 
import Vant from 'vant'
import 'vant/lib/index.css'
Vue.use(Vant)
- 测试  
App.vue 
<van-button type="primary">按钮</van-button>
总结: 如果后续不做打包优化 自动按需引入 推荐,但是我们为了开发方便后续也会做打包优化使用 全部引入 方式。
# 02-适配单位
目标:了解移动端适配方案,实现这些适配方案用到的尺寸单位。
大致步骤:
- 回忆下移动端等比例适配的单位,rem 或者 vw+vh
 - 在项目中使用 
vw来演示下适配的过程 - 总结 
vw单位在做适配的方法 
落地过程:
- rem适配,vw适配
 
// 目标:等比例适配
// 1. iphone6  375px 盒子 100*100 
// 2. iphone6 plus  414px 盒子  110.4*110.4
// rem适配
// 1. iphone6  html===>font-size:37.5px
// 2. iphone6 plus  html===>font-size:41.4px
// 3. height:2.666rem; width:2.666rem;
// vw适配
// 1. height: 26.667vw; width: 26.667vw;
- 演示 vw 效果
 
<div class="box"></div>
.box {
    height: 26.66667vw;
    width: 26.66667vw;
}
- 总结下方案,设备宽度是 
375px那么1vw === 3.75px,需要将px单位换成vw即可。 
总结: 知道如果使用vw换算px单位,实现适配效果。但是手动换算效率太低,下一节来讲如果进行自动适配。
# 03-进行适配
目标:在项目中加入vw的适配方案
大致步骤:
- 安装 postcss-px-to-viewport 插件
 - 新建一个 postcss.config.js 的配置文件
 - 添加插件配置 参考 浏览器适配 (opens new window)
 
落地代码:
- 安装
 
npm install postcss-px-to-viewport --save-dev
- 配置 
postcss.config.js 
module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      viewportWidth: 375,
    }
  }
}
- 注意,该插件对行内样式无效,建议样式通过类来定义。
 - postcss 可以认为是后处理器,对css代码做后续的处理 (转单位,加私有前缀..)
 
总结: 通过postcss-px-to-viewport插件解决移动端适配。
# 04-约定路由
目标:约定好项目的路由设计
大致步骤:
- 先了解各个页面布局的基本构成
 - 再约定好路径和组件的映射关系
 
落地规则:
| 路径 | 组件 | 功能 | 
|---|---|---|
| / | Home+Tabbar | 首页 | 
| /question | Question+Tabbar | 问答 | 
| /video | Video+Tabbar | 视频 | 
| /user | User+Tabbar | 用户 | 
| /user/profile | UserProfile | 用户资料 | 
| /user/chat | UserChat | 小智同学 | 
| /article | Article | 文章详情 | 
总结: 全部采用一级路由来实现,不使用嵌套路由。(方便后续做组件缓存)
# 05-命名视图-概念
目标:在不使用嵌套路由情况下,如何实现共同布局的复用。
官方话术: 有时候想同时 (同级) 展示多个视图,而不是嵌套展示,例如创建一个布局,有 sidebar (侧导航) 和 main (主内容) 两个视图,这个时候命名视图就派上用场了。参考地址 (opens new window)
学习步骤:
- 什么是命名视图?
- router-view组件有name属性,默认是default,可以指定名称。
 
 - 使用场景在哪里?
- 一个路由规则,可以通过命名视图,指定多个组件,组件是同级的,而不是嵌套关系。
 
 
落地分析:
- 嵌套视图:
 

- 命名视图:
 

总结: 在不使用嵌套视图情况下,可以使用命名视图来复用公用的布局内容。
# 06-命名视图-应用
目标:使用命名视图完成项目路由的基本实现。
大致步骤:
- 定义一个tabbar组件
 - 定义 首页 问答 视频 用户 文章详情 等组件
 - 使用定义路由规则,使用命名视图,组织页面。
 
落的代码:
src/components/app-tabbar.vue  底部tab切换组件
<template>
  <div class="app-tabbar">tab</div>
</template>
<script>
export default {
  name: 'AppTabbar'
}
</script>
<style scoped lang="less"></style>
src/views  中 首页 问答 视频 用户 文章详情 等组件
<template>
  <div class="home-page">首页</div>
</template>
<script>
export default {
  name: 'HomePage'
}
</script>
<style scoped lang="less"></style>
<template>
  <div class="question-page">问答</div>
</template>
<script>
export default {
  name: 'QuestionPage'
}
</script>
<style scoped lang="less"></style>
<template>
  <div class="article-page">文章详情</div>
</template>
<script>
export default {
  name: 'ArticlePage'
}
</script>
<style scoped lang="less"></style>
<template>
  <div class="user-page">用户</div>
</template>
<script>
export default {
  name: 'UserPage'
}
</script>
<style scoped lang="less"></style>
<template>
  <div class="video-page">视频</div>
</template>
<script>
export default {
  name: 'VideoPage'
}
</script>
<style scoped lang="less"></style>
src/router/index.js 路由规则
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
// 路由懒加载方式
const Tabbar = () => import('@/components/app-tabbar.vue')
const Home = () => import('@/views/home')
const Question = () => import('@/views/question')
const Video = () => import('@/views/video')
const User = () => import('@/views/user')
const Artcile = () => import('@/views/article')
const routes = [
  // 路由规则
  { path: '/', components: { default: Home, tabbar: Tabbar } },
  { path: '/question', components: { default: Question, tabbar: Tabbar } },
  { path: '/video', components: { default: Video, tabbar: Tabbar } },
  { path: '/user', components: { default: User, tabbar: Tabbar } },
  { path: '/article', component: Artcile }
]
const router = new VueRouter({
  routes
})
export default router
App.vue  组织视图
<template>
  <div id="app">
    <RouterView class="body" />
    <RouterView class="footer" name="tabbar" />
  </div>
</template>
<style lang="less" scoped>
#app {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  .body {
    flex: 1; 
    overflow: hidden;  
  }
  .footer {
    height: 50px;
  }
}
</style>
总结: 可以使用命名视图再一层路由视图情况下,复用tabbar组件。
# 07-实现tab栏
目标:使用vant的tabbar组件完成底部tab栏
大致步骤:
- 先去vant阅读下tabbar组件的模板代码
 - 再在app-tabbar.vue组件使用基础用法
 - 开启路由功能,指定跳转地址,更改文字。
 
落的代码:
src/components/app-tabbar.vue
<template>
  <van-tabbar route>
    <van-tabbar-item to="/" icon="home-o">首页</van-tabbar-item>
    <van-tabbar-item to="/question" icon="search">问答</van-tabbar-item>
    <van-tabbar-item to="/video" icon="friends-o">视频</van-tabbar-item>
    <van-tabbar-item to="/user" icon="setting-o">我的</van-tabbar-item>
  </van-tabbar>
</template>
<script>
export default {
  name: 'AppTabbar'
}
</script>
总结: van-tabbar的route是开启路由,van-tabbar-item的to属性是指定跳转地址。图标稍后处理。
# 08-字体图标
目标:掌握如何使用svg的字体图标库iconfont
大致步骤:
- 在iconfont.com上生成字体图标的js文件
 - 在public下index.html头部引入该文件
 - 使用固定的svg语法来使用图标
 
落地代码:
- 第一步:在public下index.html头部引入该文件
 
//at.alicdn.com/t/font_2496877_pghtj7rdgrh.js
- 第二步:组件中需要加入通用css代码
 
<style type="text/css">
    .icon {
       width: 1em; 
       height: 1em;
       vertical-align: -0.15em;
       fill: currentColor;
       overflow: hidden;
    }
</style>
- 第三步:挑选相应图标并获取类名,应用于页面
 
<svg class="icon" aria-hidden="true">
    <use xlink:href="#icon-xxx"></use>
</svg>

总结: 把字体图标库的js文件引入在index.html,使用svg标签通过类名来指定图标。但是每次这样使用较为麻烦,结构比较多,还有样式,封装成组件。
# 09-图标组件
目的:封装geek-icon组件来使用字体图标
大致步骤:
- 在components下定义geek-icon组件
 - 使用div.geek-icon包裹svg格式代码
 - 暴露props属性,name来指定图标
 
落地代码:
- 定义
src/components/geek-icon.vue 
<template>
  <div class="geek-icon">
    <svg class="icon" aria-hidden="true">
      <use :xlink:href="`#icon-${name}`"></use>
    </svg>
  </div>
</template>
<script>
export default {
  name: 'GeekIcon',
  props: {
    name: {
      type: String,
      default: ''
    }
  }
}
</script>
<style scoped lang="less">
.geek-icon {
  display: inline-block;
  position: relative;
  .icon {
    width: 1em;
    height: 1em;
    vertical-align: -0.15em;
    fill: currentColor;
    overflow: hidden;
  }
}
</style>
- 使用
 
<template>
  <div class="home-page">
+    首页 <geek-icon style="font-size:50px;color:green" name="weixin"></geek-icon>
  </div>
</template>
<script>
+import GeekIcon from '@/components/geek-icon'
export default {
  name: 'HomePage',
+  components: { GeekIcon }
}
</script>
<style scoped lang="less"></style>
总结: 使用geek-icon组件name是图标名称,不需要加上icon。geek-icon的颜色和字体大小可控制图标颜色。
注意: weixin图标才可以测试改颜色,其他图标没有放开颜色的设置,需要去iconfont上进行设置。
# 10-Vue插件
目标:掌握定义一个vue插件模块
大致步骤:
- 背景:项目开发过程中会有大量的一些自己写的全局组件,指令,过滤器,原型函数,如果都在main中书写,main的代码就很混乱,不利于维护。
 - 插件:可以扩展vue的原有功能在一个独立的js模块中。
 - 步骤:
- 定义一个js模块
 - 导出一个对象
 - 对象中有一个install属性指向的是一个函数
 - 函数的默认参数是 Vue
 - 你可以基于Vue做扩展功能
 
 
落地代码:
- 定义插件 
src/components/index.js 
import GeekIcon from '@/components/geek-icon'
export default {
  install (Vue) {
    // 在这里扩展Vue功能
    Vue.component(GeekIcon.name, GeekIcon)
  }
}
- 使用插件 
src/main.js 
// 导入插件
import Geek from '@/components'
// 使用插件
Vue.use(Geek)
总结: 我们在项目中一般会把 全局组件,过滤器,指令,定义在一个插件模块中。js模块的格式就是一个对象中有install函数即可。
# 11-改造底部Tab
目标:自定义使用底部tab的图标
大致步骤:
- 需要使用icon插槽自定义图标
 - 需要使用icon插槽的props作用域数据切换图标状态
 - 需要使用/deep/来覆盖组件内部样式
 
落地代码:src/components/app-tabbar.vue
<template>
  <van-tabbar :border="false" route>
    <van-tabbar-item to="/">
      <span>首页</span>
      <template #icon="props">
        <geek-icon :name="props.active ? 'home-sel' : 'home'" />
      </template>
    </van-tabbar-item>
    <van-tabbar-item to="/question">
      <span>问答</span>
      <template #icon="props">
        <geek-icon :name="props.active ? 'qa-sel' : 'qa'" />
      </template>
    </van-tabbar-item>
    <van-tabbar-item to="/video">
      <span>视频</span>
      <template #icon="props">
        <geek-icon :name="props.active ? 'video-sel' : 'video'" />
      </template>
    </van-tabbar-item>
    <van-tabbar-item to="/user">
      <span>我的</span>
      <template #icon="props">
        <geek-icon :name="props.active ? 'mine-sel' : 'mine'" />
      </template>
    </van-tabbar-item>
  </van-tabbar>
</template>
<script>
export default {
  name: 'GeekTabbar'
}
</script>
<style scoped lang="less">
.van-tabbar {
  background: #F7F8FA;
  position: static;
}
/deep/ .van-tabbar-item--active {
  color: #FC6627;
  background-color: #F7F8FA
}
/deep/ .van-tabbar-item__icon {
  font-size: 20px;
}
/deep/ .van-tabbar-item__text {
  font-size: 10px;
}
</style>
总结: 使用van-tabbar-item的作用域插槽icon可以完成图标自定义和状态切换。
样式中用到了几个颜色,这几个颜色是将来其他组件也会大量使用的,建议定义为全局变量。
# 12-Less全局变量
目标:定项目的Less全局变量
大致步骤:
- 注意,如果你将less变量定义在一个less文件中,在使用变量的地方就需要引入这个文件,麻烦。
 - 所以,vue-cli考虑到这一点,提供了配置。 参考配置 (opens new window)
 
落地代码:
- 添加配置 
vue.config.js修改配置文件需要重启项目 
module.exports = {
  css: {
    loaderOptions: {
      less: {
        // 这里定义不需要加@,使用的时候需要加@  
        globalVars: {
          'geek-color': '#FC6627',
          'geek-gray-color': '#F7F8FA'
        }
      }
    }
  }
}
- 使用变量 
src/components/app-tabbar.vue 
.van-tabbar {
+  background: @geek-gray-color;
  position: static;
}
/deep/ .van-tabbar-item--active {
+  color: @geek-color;
+  background-color: @geek-gray-color
}
总结: 在vue.config.js中添加配置就可以定义全局需要使用的less变量。
# 13-Vuex管理状态
目标:使用vuex来管理项目中需要共享的数据模块
大致步骤:
- 定义 
user用户模块,维护用户 token 等信息,且需要同步本地存储。 - 在 store 中使用 
user模块 
落地代码:
- user模块 
src/store/modules/user.js 
// 用户模块
export default {
  namespaced: true,
  state () {
    return {
      token: localStorage.getItem('geek-store-token')
    }
  },
  getters: {},
  mutations: {
    setToken (state, token) {
      state.token = token
      localStorage.setItem('geek-store-token', token)
    }
  },
  actions: {}
}
- 使用模块 
src/store/index.js 
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
Vue.use(Vuex)
export default new Vuex.Store({
  modules: {
    user
  }
})
总结: 今后组件间共享的数据由vuex来管理。
# 14-请求工具-基础
目的:为了能单独维护axios的配置,提取一个请求工具模块。
大致步骤:
- 安装 
axios且导入到文件src/utils/request.js - 创建一个新的axios实例,配置 
baseURLtimeout - 导出一个通过新axios实例调用接口的函数,返回值promise
 
落地代码: src/utils/request.js
import axios from 'axios'
// 新axios实例
const instance = axios.create({
  baseURL: 'http://geek.itheima.net/',
  timeout: 5000
})
// 导出一个新axios实例调用接口的函数,返回值promise
export default ({ url, method = 'get', params, data, headers }) => {
  const promise = instance({ url, method, params, data, headers })
  return promise
}
总结: 将来通过导出的函数调接口即可。配置可以统一在这个文件进行维护。
# 15-请求工具-赋能
目的:让请求工具能够处理,携带token问题,token失效问题。
大致步骤:
- 请求头携带token保持登录状态
 - 处理token失效问题,跳转登录页面,需要把在哪个页面失效的页面地址传递给登录页面,登录后回跳。
 
落地步骤:
- 请求头携带token保持登录状态
 
导入 vuex 仓库,然后在 请求拦截器  获取token信息,有就修改 config 的配置信息即可。
import store from '@/store'
// 请求拦截器
instance.interceptors.request.use(config => {
  const token = store.state.user.token
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
}, err => Promise.reject(err))
- 处理token失效问题,跳转登录页面,需要把在哪个页面失效的页面地址传递给登录页面,登录后回跳。
 
import router from '@/router'
// 响应拦截器
instance.interceptors.response.use(res => res, err => {
  if (err.response && err.response.status === 401) {
    // token失效
    store.commit('user/setToken', '')
    router.push('/login?returnUrl=' + encodeURIComponent(router.currentRoute.fullPath))
  }
  return Promise.reject(err)
})
总结: 我们可以在request.js 配置扩展 axios 功能,处理项目需要的业务。
# 16-await异常处理
目的:解决
async await发请求,处理异常时候多出try catch代码块问题。
大致步骤:
- 使用封装好的请求函数调用接口处理成功和异常,总结 
try catch处理异常的缺点:需要额外的代码。 - 使用 
await-to-js处理promise请求,处理成功和异常情况。 - 优化 
request.js的请求函数,让返回的promise支持await-to-js写法。 
落地代码:
- 演示 
request.js调用接口处理成功和异常 
- 演示 
 
import request from '@/utils/request'
export default {
  name: 'HomePage',
  async created () {
    try {
      const res = await request({ url: 'v1_0/channels2' })
      console.log('成功', res.data)
    } catch (e) {
      console.log('失败', e.message)
    }
  }
}
我们发现:async await 调用接口,处理异常需要额外加 try catch 的代码块,代码层次增多,不好阅读。
- 使用 
await-to-js配合request.js调用接口处理成功和异常 
- 使用 
 
import request from '@/utils/request'
import to from 'await-to-js'
export default {
  name: 'HomePage',
  async created () {
    const [err, res] = await to(request({ url: 'v1_0/channels' }))
    if (err) console.log('失败', err.message)
    else console.log('成功', res.data)
  }
}
我们可以将 to 在request.js 工具中就使用,以后就不用每次引入 await-to-js,调用to函数。
- 优化 
request.js代码 
- 安装 
npm i await-to-js - 导入 
import to from 'await-to-js' - 使用
 
export default ({ url, method = 'get', params, data, headers }) => { const promise = instance({ url, method, params, data, headers }) + return to(promise) }import request from '@/utils/request' export default { name: 'HomePage', async created () { const [err, res] = await request({ url: 'v1_0/channels' }) if (err) console.log('失败', err.message) else console.log('成功', res.data) } }- 优化 
 
总结: 使用await-to-js之后,可以通过是否存在 err 判断是否出现异常。不用使用try catch 增加代码。
# 17-接口API函数
目的:知道提取API函数的目的,掌握这种套路。
发现问题:
- 问题:调用一个接口,需要传入:请求地址,请求方式,请求参数,等等。如果这个接口需要在多个组件调用,那么相同的代码需要写多次。
 - 解决:将接口的调用再次封装成为一个函数,只暴露请求参数。这样可以提高代码复用。
 
大致步骤:
- 根据接口文档封装一个接口函数
 - 再需要数据的组件导入调用函数
 
落的代码:(在首页获取频道信息)
- 定义API函数 
src/api/channel.js 
import request from '@/utils/request'
/**
 * 获取所有频道
 */
export const getAllChannels = () => {
  return request({ url: 'v1_0/channels' })
}
- 调用API函数 
src/views/home/index.vue 
import { getAllChannels } from '@/api/channel'
export default {
  name: 'HomePage',
  async created () {
    // 不使用err不写err即可,但是,号需要写
    const [, res] = await getAllChannels()
    console.log(res.data)
  }
}
总结: 以后调用接口,先写API函数,然后再调用API函数获取数据。
# 第三章:登录模块
# 01-登录-导航守卫
目的:访问用户相关页面都需要进行登录,做一个导航守卫进行登录拦截。
大致步骤:
- 约定好将来用户相关的页面 路由地址以 
/user开头 - 在 
src/router/index.js添加前置导航守卫 - 通过vuex中是否有 
token数据来判断是否登录,进行登录拦截。 
落的代码: src/router/index.js
import store from '@/store'
// 导航守卫
router.beforeEach((to, from, next) => {
  const token = store.state.user.token
  // 没登录却访问user下的路由
  if (!token && to.path.startsWith('/user')) {
    return next('/login?returnUrl=' + encodeURIComponent(to.fullPath))
  }
  // 其他情况放行
  next()
})
总结: 使用前置导航守卫拦截未登录却访问用户页面的情况。
# 02-登录-组件布局
目的:完成登录页面基础布局,路由规则配置。
大致步骤:
- 定义登录组件
 - 配置路由规则
 - 完成基础布局
 
落的代码:
- 登录组件 
src/views/login/index.vue 
<template>
  <div class="login-page">
    <div class="back">
      <!-- .native绑定组件的原生事件,属于组件根元素 -->
      <!-- $router.back() 返回上一次访问路由,forward go -->
      <geek-icon @click.native="$router.back()" name="esay-close"></geek-icon>
    </div>
    <h3 class="title">短信登录</h3>
    <!-- 表单 -->
    <van-form>
      <van-field placeholder="请输入手机号"></van-field>
      <van-field placeholder="请输入验证码"></van-field>
    </van-form>
    <van-button>登录</van-button>  
  </div>
</template>
<script>
export default {
  name: 'LoginPage'
}
</script>
<style scoped lang="less">
.login-page {
  padding: 0 32px;
  .back {
    height: 60px;
    display: flex;
    align-items: center;
    .geek-icon {
      font-size: 20px;
      color: #ccc;
      position: relative;
      left: -15px;
    }
  }
  .title {
    font-size: 22px;
    line-height: 1;
    padding: 30px 0;
  }
  .van-cell {
    padding: 20px 0;
    &::after{
      left: 0;
      right: 0;
    }
  }
  .van-button {
    width: 100%;
    margin-top: 40px;
    height: 50px;
    color: #fff;
    font-size: 16px;
    border: none;
    background: linear-gradient(to right,#FF9999,#FFA179);
  }
}
</style>
- 路由规则 
src/router/index.js 
+const Login = () => import('@/views/login')
const routes = [
   // ...
+  { path: '/login', component: Login }
]
总结: 注意van-form和van-field是表单结构中的form和input他们需要结合使用。
# 03-登录-表单校验
目标:完成表单项校验和提交时整体校验。
大致步骤:
- 通过文档了解vant校验的套路
 - 完成单个表单项的校验
 - 完成提交时整体校验
 
落地代码:
- 根据约定vant文档总结如下:
 
1. 通过van-field组件的rules属性指定校验规则,校验单个表单项
2. 校验规则具体参考:https://vant-contrib.gitee.io/vant/#/zh-CN/form#rule-shu-ju-jie-gou
3. 通过van-form组件提供的validate函数校验全部表单项
- 完成单个表单项校验
 
  data () {
    return {
      form: {
        mobile: '',
        code: ''
      },
      rules: {
        mobile: [
          { required: true, message: '请输入手机号' },
          { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不对' }
        ],
        code: [
          { required: true, message: '请输入手机号' },
          { pattern: /^\d{6}$/, message: '验证码是6个数字' }
        ]
      }
    }
  },
      <van-field placeholder="请输入手机号" v-model="form.mobile" :rules="rules.mobile"></van-field>
      <van-field placeholder="请输入验证码" v-model="form.code" :rules="rules.code"></van-field>
- 完成提交时整体校验
 
<van-form class="form" ref="form">
  methods: {
    login () {
      this.$refs.form.validate().then(() => {
        console.log('校验成功')
      })
    }
  }
总结: 掌握单个表单项验证和整体验证可以完成大部分业务。
# 04-登录-默认登录
目的:能够通过默认246810短信验证码完成登录
大致步骤:
- 由于短信业务需要接入第三方运营商需要买短信包,所以提供了246810默认的短信验证码。
 - 编写登录 API 接口函数
 - 使用 手机号 和 默认的短信验证码246810 进行登录
 - 成功后存储 token 信息
 
落地代码:
- 编写登录 API 接口函数 
src/api/index.js 
import request from '@/utils/request'
/**
 * 登录
 * @param {String} mobile - 手机号
 * @param {String} code - 验证码
 * @returns Promise
 */
export const userLogin = ({ mobile, code }) => {
  return request({
    url: 'v1_0/authorizations',
    method: 'post',
    data: { mobile, code }
  })
}
- 使用 手机号 和 默认的短信验证码 进行登录  
src/views/login/index.vue 
import { userLogin } from '@/api/user'
  methods: {
    async login () {
      // 校验
      await this.$refs.form.validate()
      // 登录
      const [err, res] = await userLogin(this.form)
      // 失败
      if (err) return this.$toast.fail('登录失败')
      // 成功
      console.log(res)
    }
  }
- 成功后存储 token
 
src/views/login/index.vue
  methods: {
    async login () {
      // 校验
      await this.$refs.form.validate()
      // 登录
      const [err, res] = await userLogin(this.form)
      // 失败
      if (err) return this.$toast.fail('登录失败')
      // 成功
+      this.$store.commit('user/setToken', res.data.data.token)
+      this.$router.push(this.$route.query.returnUrl || '/')
    }
  }
总结: 定义API---->调用API----->得到数据,会是以后的常态。我们存储token是后续会使用的。
# 05-登录-短信登录
目的:完成发送短信验证码登录功能。
大致步骤:
- 准备 发送验证码 按钮
 - 编写发送短信API接口
 - 点击 发送验证码 按钮,
- 判断是否已经发送,
 - 校验手机号,
 - 调用发送短信API,
 - 成功后开启60秒倒计时,不可再次发送
 
 
落地代码:
- 准备 发送验证码 按钮 
src/views/login/index.vue 
      <van-field placeholder="请输入验证码" v-model="form.code" :rules="rules.code">
        <template #button>
          <span class="send">发送验证码</span>
        </template>
      </van-field>
  .send {
    font-size: 12px;
    color: #A5A6AB;
  }
- 编写发送短信API接口  
src/api/index.js 
/**
 * 发送短信验证码
 * @param {String} mobile - 手机号
 * @returns Promise
 */
export const sendMessage = (mobile) => {
  return request({
    url: `/v1_0/sms/codes/${mobile}`
  })
}
- 点击 发送验证码 按钮,校验手机号,调用发送短信API,成功后开启60秒倒计时,不可再次发送
 
src/views/login/index.vue
      // 倒计时秒数
      second: 0,
      // 定时器ID
      timer: null
        <template #button>
          <span @click="send()" class="send">
            {{second===0?'发送验证码':`${second}秒后发送`}}
          </span>
        </template>
  methods: {
     // 省略.... 
	async send () {
      // 已发送,不做事
      if (this.second > 0) return
      // 校验
      await this.$refs.form.validate('mobile')
      // 发短信
      const [err] = await sendMessage(this.form.mobile)
      // 失败
      if (err) return this.$toast.fail('发送失败')
      // 成功 倒计时
      this.second = 60
      if (this.timer) clearInterval(this.timer)
      this.timer = setInterval(() => {
        this.second--
        if (this.second <= 0) clearInterval(this.timer)
      }, 1000)
    }
  },
  beforeDestroy () {
    if (this.timer) clearInterval(this.timer)
  }
+<van-field v-model="form.mobile" name="mobile"
总结: 对应定时器,最好在销毁组件前清除定时器。
# 第四章:首页模块
# 01-首页-频道展示
目的:完成首页频道TAB展示
大致步骤:
- 使用van-tabs组件完成基础结构
 - 放置搜索按钮和频道按钮再tab组件右侧
 - 获取频道数据,渲染van-tabs组件
 
落地代码:
- 使用van-tabs组件完成基础结构 
src/views/home/index.vue 
<van-tabs>
  <van-tab v-for="index in 8" :title="'标签 ' + index">
    内容 {{ index }}
  </van-tab>
</van-tabs>
::v-deep .van-tabs {
   height: 100%;
  display: flex;
  flex-direction: column;  
  .van-tabs__line {
    background: @geek-color;
    height: 2px;
    width: 32px;
  }
  .van-tab {
    color: #9EA1AE;
  }
  .van-tab--active {
    font-size: 18px;
    color: #333;
  }
  .van-tabs__wrap {
    padding-right: 86px;
    box-shadow: 0 0 10px rgba(0,0,0,0.1);
  }
  .van-tabs__content {
    flex: 1;
    overflow: hidden;
  }
  .van-tab__pane {
    height: 100%;
  }
}
- 放置搜索按钮和频道按钮再van-tabs组件下面,定位到右上角  
src/views/home/index.vue 
    <!-- 按钮 -->
    <div class="btn-wrapper">
      <geek-icon name="search"></geek-icon>
      <geek-icon name="channel"></geek-icon>
    </div>
.home-page {
  .btn-wrapper {
    position: absolute;
    right: 0;
    top: 0;
    width: 86px;
    height: 44px;
    background: #fff;
    display: flex;
    align-items: center;
    .geek-icon {
      flex: 1;
      text-align: center;
      font-size: 18px;
    }
    &::before {
      content: "";
      width: 20px;
      height: 44px;
      position: absolute;
      left: -20px;
      top: 0;
      background: linear-gradient(to right, rgba(255,255,255,0), #fff);
    }
  }
}
- 获取频道数据,渲染van-tabs组件
 
src/api/channel.js
/**
 * 获取我的频道(未登录会返回默认的一些频道)
 */
export const getMyChannels = () => {
  return request({ url: 'v1_0/user/channels' })
}
src/views/home/index.vue
  data () {
    return {
      myChannels: []
    }
  },
  async created () {
    // 不使用err不写err即可,但是,号需要写
    const [, res] = await getMyChannels()
    this.myChannels = res.data.data.channels
  }
    <van-tabs>
      <van-tab :key="item.id" v-for="item in myChannels" :title="item.name">
        内容 {{ item.id }}
      </van-tab>
    </van-tabs>
总结: 完成tab使用,改造样式布局,获取频道数据,渲染即可。
# 02-首页-文章组件
目的:完成文章列表组件与文章单项组件
大概步骤:
- 准备文章单项组件
 - 准备文章列表组件
 - 首页使用文章列表组件
 - 首页van-tabs样式修改
 
落的步骤:
- 准备文章单项组件 
src/views/home/components/article-item.vue 
1)无图
  <div class="article-item van-hairline--bottom">
    <p class="title van-multi-ellipsis--l2">美国强行关闭33家伊朗网站 伊总统办公室警告:不利于伊核谈判</p>
    <div class="info">
      <span>小兵张嘎</span>
      <span>17评论</span>
      <span>1天前</span>
      <geek-icon name="esay-close"></geek-icon>
    </div>
  </div>
2)三图
  <div class="article-item van-hairline--bottom">
    <p class="title van-multi-ellipsis--l2">美国强行关闭33家伊朗网站 伊总统办公室警告:不利于伊核谈判</p>
    <img class="img" src="https://inews.gtimg.com/newsapp_ls/0/13685285688_294195/0" alt="">
    <img class="img" src="https://inews.gtimg.com/newsapp_ls/0/13685285688_294195/0" alt="">
    <img class="img" src="https://inews.gtimg.com/newsapp_ls/0/13685285688_294195/0" alt="">
    <div class="info">
      <span>小兵张嘎</span>
      <span>17评论</span>
      <span>1天前</span>
      <geek-icon name="esay-close"></geek-icon>
    </div>
  </div>
3)单图,title处加了w66的类名需要注意
  <div class="article-item van-hairline--bottom">
    <p class="title van-multi-ellipsis--l2 w66">美国强行关闭33家伊朗网站 伊总统办公室警告:不利于伊核谈判</p>
    <img class="img" src="https://inews.gtimg.com/newsapp_ls/0/13685285688_294195/0" alt="">
    <div class="info">
      <span>小兵张嘎</span>
      <span>17评论</span>
      <span>1天前</span>
      <geek-icon name="esay-close"></geek-icon>
    </div>
  </div>
其他代码
<script>
export default {
  name: 'ArticleItem'
}
</script>
<style scoped lang="less">
.article-item {
  padding: 15px 0;
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  .title {
    width: 100%;
    margin: 0;
    line-height: 22px;
    font-size: 16px;
    color: #333;
    margin-bottom: 8px;
    max-height: 44px;
    &.w66 {
      width: 66%;
    }
  }
  .img {
    width: 112px;
    height: 74px;
    border-radius: 4px;
    margin-bottom: 8px;
  }
  .info {
    width: 100%;
    color: #A5A6AB;
    font-size: 12px;
    position: relative;
    span {
      margin-right: 12px;
    }
    .geek-icon {
      float: right;
      font-size: 14px;
    }
  }
}
</style>
- 准备文章列表组件 
src/views/home/components/article-list.vue 
<template>
  <div class="article-list">
    <article-item v-for="i in 10" :key="i" />
  </div>
</template>
<script>
import ArticleItem from './article-item.vue'
export default {
  name: 'ArticleList',
  components: {
    ArticleItem
  }
}
</script>
<style scoped lang="less">
.article-list {
  height: 100%;
  overflow-y: auto;
  padding: 0 16px;
}
</style>
- 首页使用文章列表组件  
src/views/home/index.vue 
import { getMyChannels } from '@/api/channel'
+import ArticleList from './components/article-list.vue'
export default {
  name: 'HomePage',
+  components: { ArticleList },
    <van-tabs>
      <van-tab :key="item.id" v-for="item in myChannels" :title="item.name">
+        <article-list></article-list>
      </van-tab>
    </van-tabs>
- 首页van-tabs样式修改  
src/views/home/index.vue 
::v-deep .van-tabs {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
  .van-tabs__line {
    background: @geek-color;
    height: 2px;
    width: 32px;
  }
  .van-tab {
    color: #9EA1AE;
  }
  .van-tab--active {
    font-size: 18px;
    color: #333;
  }
  .van-tabs__wrap {
    padding-right: 86px;
  }
+  .van-tabs__content {
+    flex: 1;
+    overflow: hidden;
+  }
+  .van-tab__pane {
+    height: 100%;
+  }
}
总结: 注意下单图无图三图结构情况,注意下flex布局占满剩余高度产生滚动条。
# 03-首页-上拉加载
目的:实现上拉加载效果
大致步骤:
- 使用van-list组件,知道需要使用属性和事件作用
 - 模拟上拉加载效果
 
落地代码:src/views/home/components/article-list.vue
- 使用van-list组件,知道需要使用属性和事件作用
 
<template>
  <div class="article-list">
    <van-list v-model="loading" :finished="finished" @load="onLoad()" finished-text="没有更多了">
      <article-item v-for="i in 10" :key="i" />
    </van-list>
  </div>
</template>
<script>
import ArticleItem from './article-item.vue'
export default {
  name: 'ArticleList',
  components: {
    ArticleItem
  },
  data () {
    return {
      // 正在加载
      loading: false,
      // 数据全部加载完毕
      finished: false
    }
  },
  methods: {
    onLoad () {
      console.log('上拉加载')
    }
  }
}
</script>
v-model="loading" 是加载中状态,控制显示加载中效果,:finished="finished" 是显示是否完全加载完毕数据,
@load="onLoad()" 是上拉加载后事件,当列表最底部进入可视区(能看见列表末尾)就会触发上拉加载事件。
- 模拟上拉加载效果
 
<article-item v-for="(item, i) in articles" :key="i" />
  data () {
    return {
      // 正在加载
      loading: false,
      // 数据全部加载完毕
      finished: false,
      // 文章列表
+      articles: []
    }
  },
  methods: {
    onLoad () {
      // 模拟请求
      setTimeout(() => {
        // 加载完毕
        this.loading = false
        // 判断还有没有数据
        if (this.articles.length < 40) {
          // 设置数据,追加
          const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
          this.articles.push(...data)
        } else {
          // 设置数据全部加载完毕
          this.finished = true
        }
      }, 1000)
    }
  }
总结: 知道van-list的属性和事件,知道加载数据的套路。
# 04-首页-下拉刷新
目的:实现下拉刷新效果
大致步骤:src/views/home/components/article-list.vue
- 使用 van-pull-refresh 组件,知道需要使用的属性和事件作用
 - 模拟下拉刷新效果
 
落地代码:
- 使用 van-pull-refresh 组件,知道需要使用的属性和事件作用
 
+    <van-pull-refresh
+      v-model="refreshing"
+      @refresh="onRefresh"
+      success-text="刷新成功"
+    >
      <van-list
        v-model="loading"
        :finished="finished"
        @load="onLoad()"
        finished-text="没有更多了"
      >
        <article-item v-for="i in articles" :key="i" />
      </van-list>
+    </van-pull-refresh>
  data () {
    return {
      // 正在加载
      loading: false,
      // 数据全部加载完毕
      finished: false,
      // 文章列表
      articles: [],
      // 正在刷新
+      refreshing: false
    }
  },
  methods: {
+    onRefresh () {
+      console.log('下拉刷新')
+    },
v-model="refreshing" 是刷新中状态,控制显示刷新中效果,loading-text" 是设置刷新中的提示文字,
@refresh="onRefresh()" 是下拉刷新后事件,当用户向下拖动到一定距离后会触发下拉刷新事件。
- 模拟下拉刷新效果
 
    onRefresh () {
      // 模拟请求
      setTimeout(() => {
        // 刷新完毕
        this.refreshing = false
        // 设置数据全部加载完毕为:未加载完
        this.finished = false
        // 设置数据,替换
        const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
        this.articles = data
      }, 1000)
    },
需要注意下的是,刷新完毕后需要 将 finished 改成 false 代表还可以继续上拉加载数据,因为重置了列表数据。
总结: 知道 van-pull-refresh 组件 v-model 的作用是控制刷新中状态,@refresh 是监听下拉刷新事件。然后由于重置了数据,需要将列表改成还有更多数据状态。
# 05-首页-接入数据
目的:调用接口获取文章列表真实的数据,完成数据渲染。
大致步骤:
- 每个频道对应自己的列表,需要传入频道ID给文章列表组件。
 - 编写获取文章列表的API接口函数
 - 再上拉加载和下拉加载的位置接入真实的接口
 - 渲染单项文章组件
 
落地代码:
- 每个频道对应自己的列表,需要传入频道ID给文章列表组件。
 
src/views/home/index.vue
    <van-tabs>
      <van-tab :key="item.id" v-for="item in myChannels" :title="item.name">
+        <article-list :channelId="item.id"></article-list>
      </van-tab>
    </van-tabs>
src/views/home/components/article-list.vue
  props: {
    channelId: {
      type: Number,
      default: 0
    }
  },
- 编写获取文章列表的API接口函数
 
src/api/article.js
import request from '@/utils/request'
/**
 * 根据频道获取频道
 * @param {Number} channelId - 频道ID
 * @param {Number} timestamp - 时间戳
 * @returns
 */
export const getArticlesByChannel = (channelId, timestamp) => {
  return request({
    url: '/v1_0/articles',
    method: 'get',
    params: {
      channel_id: channelId,
      timestamp
    }
  })
}
- 在上拉加载和下拉加载的位置接入真实的接口
 
src/views/home/components/article-list.vue
import { getArticlesByChannel } from '@/api/article'
  data () {
    return {
      // 正在加载
      loading: false,
      // 数据全部加载完毕
      finished: false,
      // 文章列表
      articles: [],
      // 正在刷新
      refreshing: false,
      // 时间戳
+      timestamp: Date.now()
    }
  },
    async onRefresh () {
      // 下拉刷新
      // 1. 重置时间戳:回到第一页
      // 2. 获取数据
      // 3. 重置全部数据加载完成:可以再次加载更多
      // 4. 替换当前列表数据戳
      // 5. 记录下一次请求的时间
      // 6. 结束加刷新操作
      this.timestamp = Date.now()
      const [, res] = await getArticlesByChannel(this.channelId, this.timestamp)
      this.finished = false
      this.articles = res.data.data.results
      this.timestamp = res.data.data.pre_timestamp
      this.refreshing = false
    },
    async onLoad () {
      // 上拉加载
      // 1. 获取数据
      // 2. 判断下一页是否还有数据:当前时间戳未空,也就是没有更多了
      // 2.1 如果有:记录当前的数据时间戳,下一次请求使用
      // 2.2 如没有:设置没有更多数据
      // 3. 当前列表追加数据   
      // 4. 结束上拉加载操作
      const [err, res] = await getArticlesByChannel(this.channelId, this.timestamp)
      if (err) return this.$toast.fail('加载失败')  // 加载失败
      if (res.data.data.pre_timestamp) {
        this.timestamp = res.data.data.pre_timestamp
      } else {
        this.finished = true
      }
      this.articles.push(...res.data.data.results)
      this.loading = false
    }
  }
- 渲染单项文章组件
 
src/views/home/components/article-list.vue 传入文章信息
<article-item v-for="(item, i) in articles" :key="i" :article="item" />
src/views/home/components/article-item.vue 使用文章信息
  props: {
    article: {
      type: Object,
      default: () => ({})
    }
  }
<template>
  <div class="article-item van-hairline--bottom">
    <p class="title van-multi-ellipsis--l2" :class="{w66: article.cover.type===1}">{{article.title}}</p>
    <img v-for="(url,i) in article.cover.images" :key="i" class="img" :src="url" alt="">
    <div class="info">
      <span>{{article.aut_name}}</span>
      <span>{{article.comm_count}}评论</span>
      <span>{{article.pubdate}}</span>
      <geek-icon name="esay-close"></geek-icon>
    </div>
  </div>
</template>
总结: 使用传入的频道ID获取数据,在上拉和下拉后等所有的逻辑完成后,结束加载或刷新状态。
# 06-首页-处理时间
目的:把文章发布时间转换为相对时间,例如:2个月内,1分钟内。
大致步骤:
- 定义一个过滤器,使用过滤器证明可用。
 - 然后在过滤中实现转换逻辑,采用 dayjs 时间库处理。
- 安装导入 dayjs
 - 使用 relativeTime 模块转化相对时间
 - 使用 locale 默认语言本地化
 
 
落地代码:
- 定义一个过滤器,使用过滤器证明可用。
 
src/components/index.js
export default {
  install (Vue) {
    // 在这里扩展Vue功能
    Vue.component(GeekIcon.name, GeekIcon)
+    // 全局注册过滤器
+    Vue.filter('relativeTime', (value) => {
+      return '1周内'
+    })
  }
}
src/views/home/components/article-item.vue
<span>{{article.pubdate|relativeTime}}</span>
- 然后在过滤中实现转换逻辑,采用 dayjs 时间库处理。
 
src/components/index.js
1)安装
npm i dayjs
2)导入
import dayjs from 'dayjs'
3)使用 相对时间 插件
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
4)进行时间转换
    // 全局注册过滤器
    Vue.filter('relativeTime', (value) => {
+      return dayjs(value).toNow()
    })
5)本地化,语言
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
总结: 知道过滤器的注册使用,知道dayjs的基本使用。
# 07-首页-组件缓存
目的:首页的状态(当前浏览的频道,频道里头文章)需要保持,当你切换到其他页面时。
大致步骤:
- 知道如何通过组件缓存来保存组件状态
 - 知道如何指定某些组件进行缓存
 - 知道如何判断组件确实缓存了
 
落地代码:
src/App.vue
- 使用 keep-alive 来缓存组件
 
  <div id="app">
+    <keep-alive>
      <RouterView class="body" />
+    </keep-alive>
    <RouterView class="footer" name="tabbar" />
  </div>
那么将来 RouterView 动态展示的组件都会被缓存
- 使用 include 来指定需要被缓存的组件
 
<keep-alive include="HomePage">
属性的值是需要缓存组件的name名称,如果多个组件可以 逗号 分隔 a,b,c
- 我们可以通过 devtools 观察组件是否缓存
 

看见 inactive 代表当前组件被缓存,或者切换下组件看看组件数据状态是否保存住,还有组件被缓存了再次进入组件是不会触发 created 钩子的这也可以判断。
总结: 知道使用keep-alive组件缓存组件,知道使用include来制定被缓存组件。
# 08-首页-阅读位置
目的:组件缓存只会缓存状态数据,而滚动的位置(阅读位置)没有保持,我们需要完成阅读位置保持。
大致步骤:
- 我们需要知道如何监听:进入组件(激活组件)
 - 我们需要在阅读列表的时候记录当前滚动位置
 - 我们需要在进入组件的时候还原之前滚动位置
 
落地代码:
src/views/home/components/article-list.vue
activated和deactivated将会在keep-alive树内的所有嵌套组件中触发 参考文档 (opens new window)- 我们需要在阅读列表的时候记录当前滚动位置
 
      // 阅读位置 (data中申明)
      scrollTop: 0
<div class="article-list" ref="ArticleList" @scroll="rememberScroll">
  methods: {
    // 滚动监听
    rememberScroll () {
      this.scrollTop = this.$refs.ArticleList.scrollTop
    },
- 我们需要在进入组件的时候还原之前滚动位置
 
  // 激活组件
  activated () {
    this.$refs.ArticleList.scrollTop = this.scrollTop
  },
注意: src/views/home/index.vue
如果当前列表被隐藏,滚动失效,需要设置 animated 有动画的时候,所有列表不会隐藏,激活的时候滚动生效。
<van-tabs animated>
总结: 监听 article-list 的 scroll 事件记录 scrollTop , 然后在组件触发 activated 事件设置 article-list 的 scrollTop 最后注意 van-tabs 加上 animated 属性。
先定义API函数,组件中调用即可。
- van-tabs 组件实现频道
 - van-list 组件实现上拉加载
 - van-pull-refresh 组件实现下拉刷新
 - dayjs relativeTime模块 定义过滤器 处理时间
 - keep-alive vue内置组件做了缓存
 - 记录位置,组件激活 activated 还原位置
 
回顾 promise 知识
- 异步操作有回调函数,如果有嵌套,形成逻辑不是特别清楚的代码,回调地狱
 - promise 可以解决回调地狱问题,可以让异步操作使用then串联执行
 
// promise
ajax('url1').then(data=>{
    return ajax('url2')
}).then(data=>{
    
})
- async await 可以让promise的代码更加简洁
 
const getData = async () = >{
 await ajax('url1')
 await ajax('url2') 
}
# 第五章:频道管理
# 01-频道-组件准备
目的:定义频道管理组件,准备一个弹出层组件,在首页组件使用频道管理组件。
大致步骤:
- 了解 van-popup 组件基本用法
 - 定义频道管理组件,使用van-popup组件,修改样式
 - 在首页组件使用频道管理组件
- 实现:value来控制van-popup的显示
 - 实现@input来控制van-popup的隐藏
 - 简写成v-model
 
 
落地代码:
- 了解 van-popup 组件基本用法 参考文档 (opens new window)
 
1. v-model 或者 value 控制 弹出层显示和隐藏,值类型 true false
2. position 弹出的位置 left top bottom right
3. closeable 是否显示关闭图标
4. 事件 click-close-icon 点击关闭按钮的事件
5. 弹层大小,图标大小,使用样式覆盖控制
- 定义频道管理组件,使用van-popup组件,修改样式
 
定义组件  src/views/home/components/article-channel.vue
<template>
  <van-popup
    :value="value"
    @click-close-icon="$emit('input', false)"
    closeable
    position="left"
  >
    <div class="article-channel">
      频道管理
    </div>
  </van-popup>
</template>
<script>
export default {
  name: 'ArticleChannel',
  props: {
    value: {
      type: Boolean,
      default: false
    }
  }
}
</script>
<style scoped lang="less">
.van-popup {
  width: 100%;
  height: 100%;
  ::v-deep .van-popup__close-icon {
    font-size: 20px;
    right: 12px;
    top: 12px;
  }
}
.article-channel {
  margin-top: 44px;
}
</style>
使用组件 src/views/home/index.vue
+import ArticleChannel from './components/article-channel.vue'
export default {
  name: 'HomePage',
+  components: { ArticleList, ArticleChannel },
    <!-- 按钮 -->
    <div class="btn-wrapper">
      <geek-icon name="search"></geek-icon>
+      <geek-icon name="channel" @click.native="showChannel=true"></geek-icon>
    </div>
    <!-- 频道 -->
+    <article-channel v-model="showChannel"></article-channel>
  data () {
    return {
      myChannels: [],
+      // 控制频道组件显示隐藏
+      showChannel: false
    }
  },
总结: 封装频道管理组件,实现v-model指令,控制van-popup显示隐藏。
# 02-频道-组件布局
目的:完成频道组件的基础布局,了解需要动态交互的点。
大致步骤:
- 准备HTML结构
 - 准备CSS样式
 - 了解动态修改的类名
 
落地代码:src/views/home/components/article-channel.vue
- 准备HTML结构
 
<template>
  <van-popup
    :value="value"
    @click-close-icon="$emit('input', false)"
    closeable
    position="left"
  >
+    <div class="article-channel">
+      <div class="head">
+        <h3>我的频道<small>点击进入频道</small></h3>
+        <a class="edit" href="javascript:;">编辑</a>
+      </div>
+      <div class="body">
+        <a href="javascript:;" class="active">推荐</a>
+        <a href="javascript:;" v-for="i in 8" :key="i">频道{{i}}</a>
+      </div>
+      <div class="head" style="margin-top:12px">
+        <h3>频道推荐</h3>
+      </div>
+      <div class="body">
+        <a href="javascript:;" v-for="i in 15" :key="i">+ 频道{{i}}</a>
+      </div>
+    </div>
  </van-popup>
</template>
- 准备CSS样式
 
.article-channel {
  margin-top: 44px;
  .head {
    padding: 0 16px;
    display: flex;
    justify-content: space-between;
    justify-items: center;
    padding-bottom: 12px;
    h3 {
      font-size: 16px;
      color: #333;
      margin: 0;
      small {
        font-size: 12px;
        color: #999;
        margin-left: 10px;
      }
    }
    .edit {
      float: right;
      height: 22px;
      width: 52px;
      line-height: 22px;
      text-align: center;
      color: #DE644B;
      border-radius: 11px;
      border: 1px solid #DE644B;
      font-size: 12px;
      &.active {
        color: #fff;
        background: #DE644B;
      }
    }
  }
  .body {
    padding: 0 6px 0 16px;
    a {
      display: inline-block;
      padding: 0 8px;
      font-size:14px;
      color: #3A3948;
      background: #F7F8FA;
      height: 36px;
      line-height: 36px;
      min-width: 78px;
      margin-right: 10px;
      margin-bottom: 12px;
      border-radius: 18px;
      text-align: center;
      &.active {
        color: @geek-color;
      }
    }
  }
}
- 了解动态修改的类名
 
1. 编辑按钮----点击后---->完成按钮,需要加上active类名
2. 我的频道按钮,如果是当前浏览频道,需要加上active类名
总结: 准备好布局,知道 active 类名的作用。
# 03-频道-渲染我的频道
目的:完成我的频道渲染与激活当前浏览频道。
大致步骤:
- 完成我的频道渲染,数据从首页组件传入。
 - 完成当前浏览频道激活,首页组件准备当前浏览频道索引,传人数据给频道组件
 
落地代码:
- 完成我的频道渲染,数据从首页组件传入。
 
src/views/home/index.vue  传入我的频道数据
    <!-- 频道 -->
    <article-channel
      v-model="showChannel"
+      :myChannels="myChannels"
    >
    </article-channel>
src/views/home/components/article-channel.vue  使用我的频道数据
  props: {
    value: {
      type: Boolean,
      default: false
    },
+    myChannels: {
+      type: Array,
+      default: () => []
+    }
      <div class="head">
        <h3>我的频道<small>点击进入频道</small></h3>
        <a class="edit" href="javascript:;">编辑</a>
      </div>
      <div class="body">
+        <a
+          href="javascript:;"
+          v-for="(item,i) in myChannels"
+          :key="item.id"
+        >
+          {{item.name}}
+        </a>
      </div>
- 完成当前浏览频道激活,首页组件准备当前浏览频道索引,传人数据给频道组件。
 
src/views/home/index.vue 浏览频道索引,传给频道组件
  data () {
    return {
      myChannels: [],
      showChannel: false,
+      activeIndex: 0
    }
  },
+    <van-tabs animated v-model="activeIndex">
      <van-tab :key="item.id" v-for="item in myChannels" :title="item.name">
        <article-list :channelId="item.id"></article-list>
      </van-tab>
    </van-tabs>
    <!-- 频道 -->
    <article-channel
      v-model="showChannel"
+      :activeIndex="activeIndex"
      :myChannels="myChannels"
    >
    </article-channel>
src/views/home/components/article-channel.vue  使用浏览频道索引
  props: {
    value: {
      type: Boolean,
      default: false
    },
    myChannels: {
      type: Array,
      default: () => []
    },
+    activeIndex: {
+      type: Number,
+      default: 0
+    }
  }
      <div class="head">
        <h3>我的频道<small>点击进入频道</small></h3>
        <a class="edit" href="javascript:;">编辑</a>
      </div>
      <div class="body">
        <a
          href="javascript:;"
+          :class="{active:activeIndex===i}"
          v-for="(item,i) in myChannels"
          :key="item.id"
        >
          {{item.name}}
        </a>
      </div>
总结: 我的频道和当前浏览频道索引均在首页组件,准备好之后传递给频道组件,使用即可。
# 04-频道-渲染可选频道
目的:完成可选频道的渲染
大致步骤:
- 知道可选频道数据:就是所有频道除去我的频道剩余频道。
 - 获取所有频道数据
 - 使用计算属性得到可选频道数据
 - 进行数据渲染即可
 
落地代码:src/views/home/components/article-channel.vue
- 获取所有频道数据
 
  data () {
    return {
      allChannels: []
    }
  },
  created () {
    this.getAllChannels()
  },
  methods: {
    async getAllChannels () {
      const [, res] = await getAllChannels()
      this.allChannels = res.data.data.results
    }
  }
- 使用计算属性得到可选频道数据
 
  computed: {
    // 可选频道
    optionalChannels () {
      // 在myChannels中找不到的就是可选的
      return this.allChannels.filter(item => !this.myChannels.find(c => c.id === item.id))
    }
  },
- 进行数据渲染即可
 
      <div class="head" style="margin-top:12px">
        <h3>频道推荐</h3>
      </div>
      <div class="body">
        <a href="javascript:;" v-for="item in optionalChannels" :key="item.id">+ {{item.name}}</a>
      </div>
总结: 使用计算属性,全部频道-我的频道=可选频道。
# 05-频道-点击进入频道
目的:频道组件点击频道,关闭频道对话框,首页切换到对应频道。技术点掌握sync的使用。
大致步骤:
- 点击频道将当前的点击按钮索引传递给父组件,父组件接收后修改tabs的值即可完成切换
 - 如果传给父组件的事件时 update:属性名称,父组件传值 :属性名称。可以简写 
属性名称.sync 
落地代码:
- 完成点击进入频道功能
 
src/views/home/components/article-channel.vue
        <a
          href="javascript:;"
          :class="{active:activeIndex===i}"
          v-for="(item,i) in myChannels"
          :key="item.id"
+          @click="enterChannel(i)"
        >
    // 进入频道
    enterChannel (index) {
      // 关闭对话框
      this.$emit('input', false)
      // 传递频道索引
      this.$emit('update:activeIndex', index)
    }
src/views/home/index.vue
    <!-- 频道 -->
    <article-channel
      v-model="showChannel"
      :myChannels="myChannels"
      :activeIndex="activeIndex"
+      @update:activeIndex="activeIndex=$event"
    >
    </article-channel>
.sync也是一个语法糖,可以简写:abc="数据"@update:abc="数据=$event"代码,其实这段代码就是实现了双向数据绑定,但是组件不能使用多个v-model,所以提供了.sync来简写代码。
    <!-- 频道 -->
    <article-channel
      v-model="showChannel"
      :myChannels="myChannels"
-      :activeIndex="activeIndex"
-      @update:activeIndex="activeIndex=$event"
+      :activeIndex.sync="activeIndex"
    >
    </article-channel>
总结: 知道 .sync 语法糖作用和简写代码的规则,:abc="数据" @update:abc="数据=$event" 。
# 06-频道-支持本地操作
目的:未登录情况下获取我的频道都是写死的,服务端未提供修改接口,需要本地存储,本地修改。

大致步骤:
- 按照流程图在 原来的API函数实现
 - 修改home组件中调用API函数的地方(因为数据结构会有变化)
 
落地代码:
- 按照流程图在 原来的API函数实现
 
src/api/channel.js
/**
 * 获取我的频道(未登录会返回默认的一些频道)
 */
export const getMyChannels = async () => {
  if (!store.state.user.token) {
    const localData = JSON.parse(localStorage.getItem(KEY) || '[]')
    if (localData.length) {
      // 保持和res一样的结构,使用这个API的地方就无需修改
      return localData
    } else {
      // 获取数据本地缓存
      const [, res] = await request({ url: 'v1_0/user/channels' })
      // 只存数组
      localStorage.setItem(KEY, JSON.stringify(res.data.data.channels))
      return res.data.data.channels
    }
  } else {
    const [, res] = await request({ url: 'v1_0/user/channels' })
    return res.data.data.channels
  }
}
- 修改home组件中调用API函数的地方
 
src/views/home/index.vue
  async created () {
    // 不使用err不写err即可,但是,号需要写
    const channels = await getMyChannels()
    this.myChannels = channels
  }
总结: 在API函数完成获取我的频道逻辑业务,降低组件的业务复杂的,提高可复用性。
补充: 登录状态的切换,需要更新频道数据。
  watch: {
    '$store.state.user.token': async function () {
      const channels = await getMyChannels()
      this.myChannels = channels
      this.activeIndex = 0  
    }
  }
# 07-频道-添加
目的:完成我的频道的添加操作,支持本地和线上。

大致步骤:
- 按照流程图 定义API函数实现
 - 绑定点击事件,调用API函数添加
 
落地代码:
api/channel.js   API函数
/**
 * 添加频道
 * @param {Array<object>} myChannels - 我的频道集合
 * @param {Number} myChannels.id - 频道ID
 * @param {String} myChannels.name - 频道名称
 * @param {Number} myChannels.seq - 频道名称
 */
export const addChannel = async (myChannels) => {
  if (!store.state.user.token) {
    // 1. 获取本地
    const localData = JSON.parse(localStorage.getItem(KEY) || '[]')
    // 2. 最后一项就是需要更新的
    const { id, name } = myChannels[myChannels.length - 1]
    // 3. 追加新频道
    localData.push({ id, name })
    // 4. 存储本地
    localStorage.setItem(KEY, JSON.stringify(localData))
  } else {
    await request({
      url: '/v1_0/user/channels',
      method: 'put',
      data: { channels: myChannels }
    })
  }
}
views/home/components/article-channel.vue  添加频道
      <div class="head" style="margin-top:12px">
        <h3>频道推荐</h3>
      </div>
      <div class="body">
+        <a @click="addChannel(item)" href="javascript:;" v-for="item in optionalChannels" :key="item.id">+ {{item.name}}</a>
      </div>
    // 添加频道
    async addChannel (item) {
      // 1. 使用重置式添加频道数据,准备重置式的数据
      const newMyChannels = []
      this.myChannels.forEach((c, i) => {
        if (i !== 0) {
          newMyChannels.push({
            id: c.id,
            name: c.name,
            seq: i
          })
        }
      })
      newMyChannels.push({ ...item, seq: newMyChannels.length + 1 })
      // 2. 去做添加频道操作
      await addChannel(newMyChannels)
      // 3. 成功:更新我的频道
      this.myChannels.push(item)
    }
总结: 再添加频道到API函数处理登录和未登录的添加操作,使用重置的方式更新我的频道,需要再点击添加频道按钮后自己组织数据。
# 08-频道-切换编辑
目的:显示删除按钮,完成编辑状态切换
大致步骤:
- 准备删除按钮
 - 切换编辑状态
 
落地代码:
- 切换编辑 
src/views/home/article-channel.vue 
准备删除按钮(编辑状态,不是推荐,不是当前激活频道,才可以显示)
  .body {
    padding: 0 6px 0 16px;
    a {
+      position: relative;
+      .geek-icon {
+        position: absolute;
+        top: -5px;
+        right: -5px;
+        line-height: 1;
+      }
        <a
          href="javascript:;"
          :class="{active:activeIndex===i}"
          v-for="(item,i) in myChannels"
          :key="item.id"
          @click="enterChannel(i)"
        >
          {{item.name}}
+          <geek-icon v-show="isEdit && i!==0 && i!== activeIndex" name="tag-close"></geek-icon>
        </a>
切换编辑状态(关闭弹出层后,需要改成不编辑状态)
      <div class="head">
        <h3>我的频道<small>点击进入频道</small></h3>
+        <a class="edit" @click="isEdit=!isEdit" href="javascript:;" :class="{active:isEdit}">
+          {{isEdit?'完成':'编辑'}}
+        </a>
      </div>
  data () {
    return {
      // 全部频道
      allChannels: [],
+      // 是否编辑
+      isEdit: false
    }
  },
  <van-popup
    :value="value"
    @click-close-icon="$emit('input', false)"
    closeable
    position="left"
+    @closed="isEdit=false"
  >
# 09-频道-删除
目的:完成我的频道的删除操作,支持本地和线上。

大致步骤:
- 按照流程图 定义API函数实现
 - 绑定点击事件,调用API函数删除
 
落地代码:
- API函数 
api/channel.js 
/**
 * 删除频道
 * @param {Number} id - 频道ID
 */
export const delChannel = async (id) => {
  if (!store.state.user.token) {
    // 1. 获取本地
    const localData = JSON.parse(localStorage.getItem(KEY) || '[]')
    // 2. 删除频道
    const index = localData.findIndex(item => item.id === id)
    localData.splice(index, 1)
    // 3. 存储本地
    localStorage.setItem(KEY, JSON.stringify(localData))
  } else {
    await request({
      url: '/v1_0/user/channels/' + id,
      method: 'delete'
    })
  }
}
- 删除频道 
views/home/components/article.vue 
<!-- 给组件绑定click事件被认为是自定义事件不会被点击触发,需要加载native表示绑定的是原生事件,需要加上stop阻止事件冒泡触发进入频道的点击 -->
<geek-icon @click.native.stop="delChannel(item.id)"
    // 删除频道
    async delChannel (id) {
      // 删除操作
      await delChannel(id)
      // 成功:更新我的频道
      const index = this.myChannels.findIndex(item => item.id === id)
      this.myChannels.splice(index, 1)
    }
# 第六章:文章详情
# 01-文章详情-顶部导航
目的:完成文章详情布局,顶部导航
大致步骤:
- 完成跳转
 - 顶部导航
 
落地代码:
- 完成跳转  
src/views/home/components/article-item.vue 
<div class="article-item van-hairline--bottom" @click="$router.push('/article?id='+article.art_id)">
- 顶部导航
 
src/views/article/index.vue   点击返回按钮回退历史,固定定位,右侧按钮通过right插槽使用,全局样式按钮黑色
    <van-nav-bar left-arrow @click-left="$router.back()" fixed>
      <template #right>
        <van-icon name="ellipsis" size="5.4vw"></van-icon>
      </template>
    </van-nav-bar>
src/assets/styles/index.less
// 全局生效的样式
.van-nav-bar .van-icon{
  color: #333;
}
src/main.js
import '@/assets/styles/index.less'
# 02-文章详情-主体内容
目的:完成文章详情布局,标题,时间,作者,内容
大致步骤:
- 使用基础结构
 - 了解结构划分
 
落地代码:
src/views/article/index.vue
    <!-- 文章主体 -->
    <div class="article-wrapper">
      <!-- 头部:标题 时间 作者 -->
      <div class="header">
        <h3 class="title">第二十二节:Java语言基础-详细讲解位运算符与流程控制语句</h3>
        <div class="time">
          <span>2019年03月11日</span>
          <span>|</span>
          <span>1186 阅读</span>
          <span>|</span>
          <span>61 评论</span>
        </div>
        <div class="author van-hairline--bottom">
          <van-image
              round
              width="10vw"
              height="10vw"
              src="https://img01.yzcdn.cn/vant/cat.jpeg"
            />
          <span class="name">黑马先锋</span>
          <van-button round size="small" color="#FC6627">+关注</van-button>
        </div>
      </div>
      <!-- 内容:文章内容 -->
      <div class="main">
        <div class="html">
          <p v-for="i in 10" :key="i">我会把书籍分成两类,一类是全面型,一类是犀利型.前面介绍了一本全面型的书籍,接下来介绍的这本的特点是非常犀利,这类书籍的特点是作者能找对重点(2/8原则掌握的很好),在重点位置深入挖掘.这本书的作者John Resig也是JQuery的作者,他显然是个足够犀利的人儿.JQuery从未承诺解决所有问题,但再一些重点部位的突破,让这个类库如此流行.这本书并没有着重介绍JQuery,还是基于原生的JavaScript和DOM API.</p>
        </div> 
        <div class="space"></div>  
      </div>
      <!-- 评论:评论组件 -->
    </div>
.article-wrapper {
  height: 100%;
  overflow-y: auto;
  padding: 44px 0 50px;
  // 头部
  .header {
    padding: 0 16px;  
    .title {
      font-size: 20px;
      font-weight: normal;
      padding: 10px 0;
      margin: 0;
    }
    .time {
      font-size: 12px;
      color: #999;
      span:nth-child(2n) {
        margin: 0 5px;
        color: #ccc;
        position: relative;
        top: -1px;
      }
    }
    .author {
      display: flex;
      align-items: center;
      padding: 10px 0 ;
      .name {
        flex: 1;
        padding-left: 10px;
        font-size: 16px;
      }
    }
  }
  // 内容
  .main {
    .space {
      height: 16px;
      background: @geek-gray-color;
    }
    .html {
      word-break: break-all;
      width: 100%;
      overflow: hidden;
      padding: 20px 16px;
      /deep/ img {
        max-width:100%;
        background: #f9f9f9;
      }
      /deep/ pre {
        white-space: pre-wrap;
        code {
          white-space: pre;
        }
      }
    }
  }
}
src/assets/styles/index.less
* {
  box-sizing: border-box;
}
总结: article-wrapper容器装:header main comment组件
# 03-文章详情-渲染页面
目的:获取文章详情数据进行渲染 7974 这个文章数据不错哦
大致步骤:
- 定义API接口函数
 - 定义数据,获取数据
 - 渲染页面
 
落地代码:
- 定义API接口函数  
src/api/article.js 
/**
 * 获取文章详情
 * @param {String} id - 文章ID
 * @returns
 */
export const getArticle = (id) => {
  return request({
    url: '/v1_0/articles/' + id
  })
}
- 定义数据,获取数据 
src/views/article/index.vue 
import { getArticle } from '@/api/article'
export default {
  name: 'ArticlePage',
  data () {
    return {
      article: {}
    }
  },
  created () {
    this.getArticle()
  },
  methods: {
    async getArticle () {
      const [, res] = await getArticle(this.$route.query.id)
      this.article = res.data.data
    }
  }
}
- 渲染页面 
src/views/article/index.vue 
    <!-- 文章主体 -->
    <div class="article-wrapper">
      <!-- 头部:标题 时间 作者 -->
      <div class="header">
        <h3 class="title">{{article.title}}</h3>
        <div class="time">
          <span>{{article.pubdate}}</span>
          <span>|</span>
          <span>{{article.read_count}} 阅读</span>
          <span>|</span>
          <span>{{article.comm_count}} 评论</span>
        </div>
        <div class="author van-hairline--bottom">
          <van-image round width="10vw" height="10vw" :src="article.aut_photo"/>
          <span class="name">{{article.aut_name}}</span>
          <van-button round size="small" color="#FC6627">+ 关注</van-button>
        </div>
      </div>
      <!-- 内容:文章内容 -->
      <div class="main">
        <div class="html" v-html="article.content"></div>
        <div class="space"></div>
      </div>
      <!-- 评论:评论组件 -->
    </div>
总结: 富文本内容使用v-html渲染
# 04-文章详情-导航交互
目的:完成在滚动页面时候,头部被卷起后,在顶部导航显示作者信息。
大致步骤:
- 准备顶部导航作者信息
 - 监听滚动,显示隐藏作者信息
 
落地代码:
- 准备顶部导航作者信息 
src/views/article/index.vue 
    <!-- 导航 -->
    <van-nav-bar left-arrow @click-left="$router.back()" fixed>
+      <template #title>
+        <div class="nav-author">
+          <van-image round width="7vw" height="7vw" :src="article.aut_photo"/>
+          <span class="name">{{article.aut_name}}</span>
+          <span class="line">|</span>
+          <span class="follow">关注</span>
+        </div>
+      </template>
      <template #right>
        <van-icon name="ellipsis" size="5.4vw"></van-icon>
      </template>
    </van-nav-bar>
/deep/ .van-nav-bar__title {
   max-width: 270px;
   width: 270px;
}
.nav-author {
  display: flex;
  justify-content: flex-start;
  align-items: center;
  > span {
    font-size: 14px;
    padding-left: 5px;
  }
  .line {
    color: #ccc;
    position: relative;
    top: -1px;
  }
  .follow {
    color: @geek-color;
  }
}
- 监听滚动,显示隐藏作者信息  
src/views/article/index.vue 
    <!-- 文章主体 -->
    <div class="article-wrapper" ref="wrapper" @scroll="onScroll">
      <!-- 头部:标题 时间 作者 -->
      <div class="header" ref="header">
  data () {
    return {
      article: {},
      showNavAuthor: false
    }
  },
  methods: {
    // 监听滚动
    onScroll () {
      const scrollTop = this.$refs.wrapper.scrollTop
      const headerHeight = this.$refs.header.offsetHeight
      this.showNavAuthor = scrollTop > headerHeight
    },
      <template #title>
        <div class="nav-author" v-show="showNavAuthor">
总结: 通过ref获取wrapper的卷起高度 比较 header的高度,大于就显示头部导航的作者信息
# 05-文章详情-关注作者
目的:完成关注作者和取消关注功能
大致步骤:
- 定义关注作者和取消关注的API接口函数
 - 绑定点击事件,完成关注和取消关注操作
 - 切换关注按钮的显示
 
落地代码:
- API接口函数  
src/api/user.js 
/**
 * 关注 和 取消关注
 * @param {*} authorId - 作者ID
 * @param {*} isFollow - 是否关注
 * @returns Promise
 */
export const followAuthor = (authorId, isFollow) => {
  if (isFollow) {
    return request({
      url: '/v1_0/user/followings',
      method: 'post',
      data: { target: authorId }
    })
  } else {
    return request({
      url: '/v1_0/user/followings/' + authorId,
      method: 'delete'
    })
  }
}
- 组件中处理:关注 和 取消关注   
src/views/article/index.vue 
    // 关注&取消关注
    async followAuthor () {
      const newStatus = !this.article.is_followed
      const [err] = await followAuthor(this.article.aut_id, newStatus)
      if (err) {
        return this.$toast.success('操作失败')
      }
      this.$toast.success(newStatus ? '关注成功' : '取消关注')
      this.article.is_followed = newStatus
    },
- 切换按钮
 
          <span @click="followAuthor()" class="follow" :class="{un:article.is_followed}">
            {{article.is_followed?'取消关注':'关注'}}
          </span>
          <van-button v-if="article.is_followed" @click="followAuthor()" round size="small" >取消关注</van-button>
          <van-button v-else @click="followAuthor()" round size="small" color="#FC6627">+ 关注</van-button>
  .follow {
    color: @geek-color;
+    // 不加&  .follow .un   后代选择器
+    // 加上&  .follow.un    交集选择器
+    &.un {
+      color: #999;
+    }
  }
# 06-文章详情-骨架效果
目的:在文章加载过程中加上骨架效果
大致步骤:
- 定义一个加载中状态数据
 - 再请求前改成加载中,请求后改成加载完成
 - 根据数据显示骨架效果
 
落地代码:  src/views/article/index.vue
- 数据
 
  data () {
    return {
      article: {},
      showNavAuthor: false,
+      loading: false
    }
  },
- 状态设置
 
    // 文章详情
    async getArticle () {
+      this.loading = true
      const [, res] = await getArticle(this.$route.query.id)
      this.article = res.data.data
+      this.loading = false
    }
- 骨架效果
 
    <!-- 骨架组件 -->
    <div v-if="loading" class="article-skeleton">
      <van-skeleton title :row="12" />
    </div>
    <!-- 文章主体 -->
    <div v-else class="article-wrapper" ref="wrapper" @scroll="onScroll">
.article-skeleton {
  padding-top: 60px;
}
# 07-文章详情-代码高亮
目的:技术类文章,会有code标签写的代码,给代码加上高亮样式
 大致步骤:
- 安装 
highlight.js插件 - 分析使用过程:先有html结构,使用插件给结构加样式。
 - 决定封装成指令
 
落地代码:
- 安装
 
npm i highlight.js@10.7.2
- 使用分析
 
使用:
// 文档结构加载完毕事件
document.addEventListener('DOMContentLoaded', (event) => {
    // 找到所有的 pre 下的 code  标签
  document.querySelectorAll('pre code').forEach((el) => {
    // 转化成代码结构的html标签
    hljs.highlightElement(el);
  });
})
风格:
// 存放风格样式文件目录
geek-client-mobile\node_modules\highlight.js\styles
- 指令封装 
src/components/index.js 
由于是某一个标签内的结构需要加上样式需要操作dom,而且需要内容渲染完毕后去操作,使用指令比较合适。
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
    // install函数 定义全局指令
    Vue.directive('highlight', (el) => {
      const codeList = el.querySelectorAll('pre code')
      codeList.forEach((code) => {
        hljs.highlightElement(code) 
      })
    })
- 使用指令   
src/views/article/index.vue 
      <!-- 内容:文章内容 -->
      <div class="main" v-html="article.content" v-highlight></div>
总结:使用自定义指令来实现代码高亮效果。
# 第七章:评论回复
# 01-文章评论-组件布局
目的:准备评论回复组件的基础布局结构,了解结构
 大致步骤:
- 准备基础布局结构
 - 使用组件
 - 分析了解布局结构
 
落地代码:src/views/article/components/article-comment.vue
<template>
  <div class="article-comment">
    <!-- 全部评论 -->
    <van-sticky offset-top="11.73333vw">
      <div class="title van-hairline--bottom">
        <span>全部评论 (0)</span>
        <span>0 点赞</span>
      </div>
    </van-sticky>
    <!-- 评论列表 -->
    <div class="list">
      <div class="item van-hairline--bottom" v-for="i in 10" :key="i">
        <van-image round width="10vw" height="10vw" src="https://img01.yzcdn.cn/vant/cat.jpeg"/>
        <div class="info">
          <p>
            <span class="name">清风徐来</span>
            <span class="zan">0 <geek-icon name="like2" /></span>
          </p>
          <p class="cont">说的不错!</p>
          <p>
            <span class="reply">回复 <i class="van-icon van-icon-arrow"></i></span>
            <span class="time">2小时内</span>
          </p>
        </div>
      </div>
    </div>
    <!-- 底部工具 -->
    <div class="footer"></div>
  </div>
</template>
<script>
export default {
  name: 'ArticleComment'
}
</script>
<style scoped lang="less">
.article-comment {
  .title {
    height: 50px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0 16px;
    background: #fff;
    span {
      font-size: 16px;
      &:last-child {
        color: #ccc;
        font-size: 14px;
      }
    }
  }
  .list {
    padding: 0 16px;
    .item {
      display: flex;
      padding: 10px 0;
      .info {
        padding-left: 10px;
        flex: 1;
        p {
          margin: 0;
          .name {
            font-size: 16px;
          }
          .zan {
            font-size: 14px;
            float: right;
            color: #999;
            .geek-icon {
              font-size: 12px;
              position: relative;
              top: -1px;
            }
          }
          &.cont {
            font-size: 14px;
            color: #666;
            padding: 10px 0;
            word-break: break-all;
            padding-right: 40px;
          }
          .reply {
            min-width: 60px;
            height: 24px;
            text-align: center;
            line-height: 28px;
            font-size: 12px;
            background: @geek-gray-color;
            display: inline-block;
            border-radius: 14px;
            color: #666;
            .van-icon {
              position: relative;
              top: 1px;
            }
          }
          .time {
            font-size: 12px;
            color: #999;
            margin-left: 10px;
          }
        }
      }
    }
  }
}
</style>
- 使用组件 
src/views/article/index.vue 
      <!-- 评论:评论组件 -->
      <article-comment />
import ArticleComment from './components/article-comment.vue'
export default {
  name: 'ArticlePage',
  components: { ArticleComment },
- 分析了解布局结构
 
1. 全部评论,盒子需要滚动到顶部固定定位,使用van-sticky offset-top="11.73333vw"
2. 评论列表,
3. 底部工具
# 02-文章评论-渲染列表
目的:完成文章评论列表上拉加载,渲染列表
大致步骤:
- 使用van-list组件完成上拉加载效果,初始化就加载一次
 - 定义API函数获取文章评论
 - 使用API函数获取数据,完成列表渲染
 
落地代码:
- 使用van-list组件完成上拉加载效果 
src/views/article/components/article-comment.vue 
    <!-- 评论列表 -->
    <div class="list">
+      <van-list v-model="loading" :finished="finished" finished-text="没有评论了" @load="onLoad">
        <div class="item van-hairline--bottom" v-for="i in 10" :key="i">
          <van-image round width="10vw" height="10vw" src="https://img01.yzcdn.cn/vant/cat.jpeg"/>
          <div class="info">
            <p>
              <span class="name">清风徐来</span>
              <span class="zan">0 <geek-icon name="like2" /></span>
            </p>
            <p class="cont">说的不错!</p>
            <p>
              <span class="reply">回复 <i class="van-icon van-icon-arrow"></i></span>
              <span class="time">2小时内</span>
            </p>
          </div>
        </div>
+      </van-list>
    </div>
  data () {
    return {
      loading: false,
      finished: false
    }
  },
  methods: {
    onLoad () {
      console.log('加载评论')
    }
  }
- 定义API函数获取文章评论 
src/api/article.js 
/**
 * 获取文章评论
 * @param {String} articleId - 文章ID
 * @param {String} offset - 上一页数据最后一个ID,做为下一页请求的偏移量
 */
export const getCommentsByArticle = (articleId, offset) => {
  return request({
    url: '/v1_0/comments',
    params: { type: 'a', source: articleId, offset }
  })
}
- 使用API函数获取数据,完成列表渲染
 
传人文章对象 src/views/article/index.vue
      <!-- 评论:评论组件 -->
      <article-comment :article="article" />
获取数据 src/views/article/components/article-comment.vue
import { getCommentsByArticle } from '@/api/articles'
  data () {
    return {
      loading: false,
      finished: false,
      offset: null,
      comments: []
    }
  },
  methods: {
    async onLoad () {
      // 获取数据
      const [, res] = await getCommentsByArticle(this.article.art_id, this.offset)
      // 这次数据最后一条ID和所有数据最后一条ID相同,没有数据了
      if (res.data.data.last_id === res.data.data.end_id) {
        // 加载完毕
        this.finished = true
      } else {
        // 记录偏移量,为下次请求准备
        this.offset = res.data.data.last_id
      }
      // 追加评论数据
      this.comments.push(...res.data.data.results)
      // 结束加载状态
      this.loading = false
    }
  }
进行渲染列表  src/views/article/components/article-comment.vue
        <div class="item van-hairline--bottom" v-for="item in comments" :key="item.com_id">
          <van-image round width="10vw" height="10vw" :src="item.aut_photo"/>
          <div class="info">
            <p>
              <span class="name">{{item.aut_name}}</span>
              <span class="zan">{{item.like_count}}
                <geek-icon :name="item.is_liking?'like-sel':'like2'" />
              </span>
            </p>
            <p class="cont">{{item.content}}</p>
            <p>
              <span class="reply">{{item.reply_count}}回复 <i class="van-icon van-icon-arrow"></i></span>
              <span class="time">{{item.pubdate|relativeTime}}</span>
            </p>
          </div>
        </div>
渲染总评数量
      <div class="title van-hairline--bottom">
        <span>全部评论 ({{article.comm_count}})</span>
        <span>{{article.like_count}} 点赞</span>
      </div>
# 03-文章评论-底部工具
目的:完成底部工具栏的布局和渲染。
 大致步骤:
- 完成底部工具栏布局,了解结构和样式
 - 使用 article 完成渲染
 
落地代码: src/views/article/components/article-comment.vue
- 工具栏结构
 
    <!-- 底部工具 -->
    <div class="footer van-hairline--top">
      <div class="input"><i class="van-icon van-icon-edit"></i></div>
      <div class="btn"><geek-icon name="comment"></geek-icon><p>评论</p><i>0</i></div>
      <div class="btn"><geek-icon name="like"></geek-icon><p>点赞</p></div>
      <div class="btn"><geek-icon name="collect"></geek-icon><p>收藏</p></div>
      <div class="btn"><geek-icon name="share"></geek-icon><p>分享</p></div>
    </div>
- 工具栏样式
 
    .footer {
    position: fixed;
    left: 0;
    bottom: 0;
    height: 50px;
    background: #fff;
    display: flex;
    width: 100%;
    align-items: center;
    .input {
      margin-left: 10px;
      width: 200px;
      height: 34px;
      background: @geek-gray-color;
      border-radius: 17px;
      line-height: 36px;
      padding-left: 10px;
      .van-icon {
        color: #999;
      }
    }
    .btn {
      flex: 1;
      text-align: center;
      position: relative;
      p {
        margin: 0;
        font-size: 10px;
      }
      .geek-icon {
        font-size: 18px;
      }
      i {
        height: 16px;
        min-width: 16px;
        padding: 0 3px;
        background: @geek-color;
        color: #fff;
        font-size: 10px;
        position: absolute;
        right: 0;
        top: -4px;
        line-height: 16px;
        border-radius: 8px;
        font-style: normal;
      }
    }
  }
- 完成渲染
 
    <!-- 底部工具 -->
    <div class="footer van-hairline--top">
      <div class="input"><i class="van-icon van-icon-edit"></i></div>
      <div class="btn">
        <geek-icon name="comment"></geek-icon><p>评论</p>
        <i v-if="article.comm_count">{{article.comm_count}}</i>
      </div>
      <div class="btn">
        <geek-icon :name="article.attitude===1?'like-sel':'like'"></geek-icon>
        <p>点赞</p>
      </div>
      <div class="btn">
        <geek-icon :name="article.is_collected?'collect-sel':'collect'"></geek-icon>
        <p>收藏</p>
      </div>
      <div class="btn"><geek-icon name="share"></geek-icon><p>分享</p></div>
    </div>
注意: attitude === 1 代表点赞,其他代表未点赞。可以通过dev-tools切换数据,测试状态。
# 04-文章评论-传送功能
目的:评论按钮滚动到评论区,再次点击回到顶部。
 大致步骤:
- 分析:评论区位置 = 头部高度 + 内容高度 , 组件初始化就需要加载评论。
 index.vue组件操作滚动方便,article-wrapper是滚动容器- 点击评论按钮,触发自定义时间,父组件给 
article-commont绑定,进行切换位置 
落地代码:
- 触发事件  
src/views/article/components/article-comment.vue 
+      <div class="btn" @click="$emit('click-comment')">
        <geek-icon name="comment"></geek-icon><p>评论</p>
        <i v-if="article.comm_count">{{article.comm_count}}</i>
      </div>
- 绑定事件  
src/views/article/index.vue 
  data () {
    return {
      article: {},
      showNavAuthor: false,
      loading: false,
      // 是否滚动到评论位置
+      toComment: false
    }
  },
      <!-- 评论:评论组件 -->
      <article-comment :article="article" @click-comment="srollToComment" />
      <!-- 内容:文章内容 -->
      <div class="main" ref="main">
    // 滚动到评论
    srollToComment () {
      const headerHeight = this.$refs.header.offsetHeight
      const mainHeight = this.$refs.main.offsetHeight
      // 是否滚动到评论,切换状态
      this.toComment = !this.toComment
      // 来回切换
      if (this.toComment) {
        this.$refs.wrapper.scrollTop = headerHeight + mainHeight
      } else {
        this.$refs.wrapper.scrollTop = 0
      }
    },
- 默认加载评论(否则页面下面没数据撑开高度,滚动不过去)  
src/views/article/components/article-comment.vue 
  created () {	
    // 开启加载中效果  
    this.loading = true
    this.onLoad()
  },
# 05-文章评论-点赞
目的:对文章点赞,取消点赞。
大致步骤:
- 定义给文章点赞API
 - 绑定点击,完成点赞
 
落地代码:
- API函数 
src/api/article.js 
/**
 * 点赞文章,取消点赞
 * @param {String} articleId - 文章ID
 * @param {Boolean} isLike - 是否点赞
 */
export const likeArticle = (articleId, isLike) => {
  if (isLike) {
    return request({
      url: '/v1_0/article/likings',
      method: 'post',
      data: { target: articleId }
    })
  } else {
    return request({
      url: '/v1_0/article/likings/' + articleId,
      method: 'delete'
    })
  }
}
- 处理逻辑 
src/views/article/components/article-comment.vue 
      <div class="btn" @click="likeArticle">
        <geek-icon :name="article.attitude===1?'like-sel':'like'"></geek-icon>
        <p>点赞</p>
      </div>
+ import { getCommentsByArticle, likeArticle } from '@/api/articles'
  methods: {
    async likeArticle () {
      if (this.article.attitude === 1) {
        const [err] = await likeArticle(this.article.art_id, false)
        if (err) return this.$toast.fail('操作失败')
        this.article.attitude = -1
      } else {
        const [err] = await likeArticle(this.article.art_id, true)
        if (err) return this.$toast.fail('操作失败')
        this.article.attitude = 1
      }
      this.$toast.success('操作成功')
    },
# 06-文章评论-收藏
目标:对文章收藏,取消收藏。
大致步骤:
- 定义给收藏文章API
 - 绑定点击,完成收藏
 
落地代码:
- API函数 
src/api/article.js 
/**
 * 收藏文章,取消收藏
 * @param {String} articleId - 文章ID
 * @param {Boolean} isCollect - 是否收藏
 */
export const collectArticle = (articleId, isCollect) => {
  if (isCollect) {
    return request({
      url: '/v1_0/article/collections',
      method: 'post',
      data: { target: articleId }
    })
  } else {
    return request({
      url: '/v1_0/article/collections/' + articleId,
      method: 'delete'
    })
  }
}
- 处理逻辑
 
      <div class="btn" @click="collectArticle">
        <geek-icon :name="article.is_collected?'collect-sel':'collect'"></geek-icon>
        <p>收藏</p>
      </div>
+ import { getCommentsByArticle, likeArticle, collectArticle } from '@/api/articles'
    async collectArticle () {
      const [err] = await collectArticle(this.article.art_id, !this.article.is_collected)
      if (err) return this.$toast.fail('操作失败')
      this.article.is_collected = !this.article.is_collected
      this.$toast.success('操作成功')
    },
# 07-评论回复-回复弹层
目的:完成点击回复按钮,弹出回复页面,完成页面布局。
 大致步骤:
准备弹窗,点击回复切换
完成头部,和底部
完成回复列表
落地代码: src/views/article/components/article-comment.vue
- 准备弹窗
 
    <!-- 回复弹层 -->
    <van-popup v-model="reply.open" position="right">
      <!-- 头 -->
      <van-nav-bar left-arrow @click-left="reply.open=false" title="0条回复" />
      <div class="reply-wrapper list">
        <!-- 列表 -->
        <div class="item van-hairline--bottom" v-for="i in 10" :key="i">
          <van-image round width="10vw" height="10vw" src="https://img01.yzcdn.cn/vant/cat.jpeg"/>
          <div class="info">
            <p>
              <span class="name">清风徐来</span>
              <span class="zan">0 <geek-icon name="like2" /></span>
            </p>
            <p class="cont">说的不错!</p>
            <p><span class="time" style="margin-left:0">2小时内</span></p>
          </div>
        </div>
      </div>
      <!-- 底 -->
      <div class="footer van-hairline--top" style="position:static">
        <div class="input big"><i class="van-icon van-icon-edit"></i></div>
        <div class="btn">
          <geek-icon name="collect"></geek-icon><p>收藏</p>
        </div>
        <div class="btn"><geek-icon name="share"></geek-icon><p>分享</p></div>
      </div>
    </van-popup>
    .input {
+      &.big {
+        width: 260px;
+      }
// 最下面加如下样式
.van-popup {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.reply-wrapper {
  width: 100%;
  height: 100%;
  overflow-y: auto;
}
  data () {
    return {
      loading: false,
      finished: false,
      offset: null,
      comments: [],
+     // 回复业务需要的数据
+      reply: {
+        open: false
+      }
    }
  },
- 点击回复打开
 
              <span class="reply" @click="clickReply(item)">
    clickReply (item) {
      // 打开评论弹窗
      this.reply.open = true
    },
# 08-评论回复-列表渲染
目的:完成评论的回复列表数据获取和渲染
大致步骤:
- 编写获取回复列表API函数
 - 打开对话框记录当前被回复的评论信息对象,打开的时候,需要重置数据
 - 使用van-list加载数据,但是不能默认加载,当打开回复对话框
 
落地代码:
- API函数 
src/api/article.js 
/**
 * 获取评论回复
 * @param {String} commentId - 评论ID
 * @param {String} offset - 上一页数据最后一个ID,做为下一页请求的偏移量
 */
export const getReplysByComment = (commentId, offset) => {
  return request({
    url: '/v1_0/comments',
    params: { type: 'c', source: commentId, offset }
  })
}
- 打开对话框  
src/views/article/components/article-comment.vue 
1)准备数据
      // 回复业务需要的数据
      currComments: {},
      reply: {
        open: false,
        loading: false,
        finished: false,
        offset: null,
        list: []
      }
2)打开组件
    clickReply (item) {
      // 记录当前评论
      this.currComments = item
      // 打开评论弹窗
      this.reply.open = true
      // 加载效果
      this.reply.loading = true
      // 重置加载完毕
      this.reply.finished = false
      // 重置数据
      this.reply.list = []
      // 重置请求参数
      this.reply.offset = null
      // 开启加载
      this.loadReply()
    },
        <van-list :immediate-check="false" v-model="reply.loading" :finished="reply.finished" finished-text="没有回复了" @load="loadReply">
3)获取数据:
+ import { getCommentsByArticle, likeArticle, collectArticle, getReplysByComment } from '@/api/article'
    async loadReply () {
      const [, res] = await getReplysByComment(this.currComments.com_id, this.reply.offset)
      this.reply.list.push(...res.data.data.results)
      if (res.data.data.end_id === res.data.data.last_id) {
        this.reply.finished = true
      } else {
        this.reply.offset = res.data.data.last_id
      }
      this.reply.loading = false
    },
4)进行渲染:
          <!-- 列表 -->
          <div class="item van-hairline--bottom" v-for="item in reply.list" :key="item.com_id">
            <van-image round width="10vw" height="10vw" :src="item.aut_photo"/>
            <div class="info">
              <p>
                <span class="name">{{item.aut_name}}</span>
                <span class="zan">{{item.like_count}} <geek-icon name="like2" /></span>
              </p>
              <p class="cont">{{item.content}}</p>
              <p><span class="time" style="margin-left:0">{{item.pubdate|relativeTime}}</span></p>
            </div>
          </div>
# 09-填写文章评论
目的:发表对文章评论
大致步骤:
- 准备填写文字的弹出框
 - 定义API接口函数,点击发表提交评论
 
落地代码:
- 准备填写文字的弹出框   
src/views/article/components/article-comment.vue 
1)弹出框
    <!-- 评论&回复 -->
    <van-popup v-model="showInput" position="bottom">
      <van-nav-bar left-arrow @click-left="showInput=false" title="评论文章" right-text="发表" />
      <van-field
        v-model="text"
        rows="3"
        autosize
        type="textarea"
        maxlength="100"
        placeholder="请输入评论"
        show-word-limit
      />
    </van-popup>
::v-deep .van-nav-bar__text {
  color: @geek-color;
}
::v-deep .van-field__control {
  background: @geek-gray-color;
  padding: 5px 10px;
  margin-bottom: 5px;
  border-radius: 4px;
}
2)数据
      // 显示输入弹窗
      showInput: false,
      // 输入框值
      text: ''
3)打开对话框
    <!-- 底部工具 -->
    <div class="footer van-hairline--top">
      <div class="input" @click="showInput=true">
- 定义API接口函数,点击发表提交评论,更新数据,关闭对话框
 
src/api/article.js
/**
 * 对文章进行评论,对评论进行回复
 * @param {*} target - 评论:文章ID,回复:评论ID
 * @param {*} content - 内容
 * @param {*} articleId - 回复:文章ID
 * @returns
 */
export const commentOrReply = (target, content, articleId = null) => {
  return request({
    url: '/v1_0/comments',
    method: 'post',
    data: { target, content, art_id: articleId }
  })
}
src/views/article/components/article-comment.vue
      <van-nav-bar
        left-arrow
        @click-left="showInput=false"
        title="评论文章"
        right-text="发表"
+        @click-right="submit()"
      />
import {
  getCommentsByArticle,
  likeArticle,
  collectArticle,
  getReplysByComment,
+  commentOrReply
} from '@/api/articles'
    async submit () {
        // 1. 提交文章评论
        // 1.1. 校验是否输入内容
        // 1.2. 调用接口
        // 1.3. 失败提示
        // 1.4. 更新评论列表+更新评论数量+关闭对话框+清除输入内容+成功提示
      if (!this.text) return this.$toast('请输入评论')
      const [err, res] = await commentOrReply(this.article.art_id, this.text)
      if (err) return this.$toast.fail('评论失败')
      this.comments.unshift(res.data.data.new_obj)
      this.article.comm_count++
      this.showInput = false
      this.text = ''
      this.$toast.success('评论成功')
    },
# 10-填写评论回复
目的:发表对评论的回复
大致步骤:
- 关闭回复列表弹出框清除评论对象,(判断此操作是回复还是评论,根据对象是否有数据)
 - 点击发表按钮,在 
submit函数,合并提交回复功能 
落地代码:
- 关闭回复列表弹出框清除评论对象,动态渲染输入框 placeholder 和 弹出框标题
 
    <!-- 回复弹层 -->
    <van-popup v-model="reply.open" @closed="currComments={}" position="right">
    <!-- 评论&回复 -->
    <van-popup v-model="showInput" position="bottom">
      <van-nav-bar
        left-arrow
        @click-left="showInput=false"
+        :title="currComments.com_id?'回复评论':'评论文章'"
        right-text="发表"
        @click-right="submit()"
      />
      <van-field
        v-model="text"
        rows="3"
        autosize
        type="textarea"
        maxlength="100"
+        :placeholder="currComments.com_id?`@${currComments.aut_name}`:'请输入评论'"
        show-word-limit
      />
    </van-popup>
- 合并提交回复功能
 
    async submit () {
+      if (!this.currComments.com_id) {
        // 1. 提交文章评论
        // 1.1. 校验是否输入内容
        // 1.2. 调用接口
        // 1.3. 失败提示
        // 1.4. 更新评论列表+更新评论数量+关闭对话框+清除输入内容+成功提示
        if (!this.text) return this.$toast('请输入评论')
        const [err, res] = await commentOrReply(this.article.art_id, this.text)
        if (err) return this.$toast.fail('评论失败')
        this.comments.unshift(res.data.data.new_obj)
        this.article.comm_count++
        this.showInput = false
        this.text = ''
        this.$toast.success('评论成功')
+      } else {
+        // 2. 提交评论回复
+        // 2.1. 校验是否输入内容
+        // 2.2. 调用接口
+        // 2.3. 失败提示
+        // 2.4. 更新回复列表+更新评论数量+更新回复数量+关闭对话框+清除输入内容+成功提示
+        if (!this.text) return this.$toast('请输入回复')
+        const [err, res] = await commentOrReply(this.currComments.com_id, this.text, this.article.art_id)
+        if (err) return this.$toast.fail('回复失败')
+        this.reply.list.unshift(res.data.data.new_obj)
+        this.article.comm_count++
+        this.currComments.reply_count++
+        this.showInput = false
+        this.text = ''
+        this.$toast.success('回复成功')
+      }
    },
# 第八章:个人中心
# 01-个人中心-基础布局
目的:完成个人中心首页基础布局
 大致步骤:
- 根据笔记完成布局
 - 了解布局结构样式
 
落地代码: src/user/index.vue
- 根据笔记完成布局
 
<template>
  <div class='user-page'>
    <div class="user-profile">
      <div class="info">
        <van-image round fit="cover" src="https://img01.yzcdn.cn/vant/cat.jpeg" />
        <h3 class="name">清风徐来</h3>
        <router-link class="btn" to="/user/profile">个人信息<van-icon name="arrow" /></router-link>
      </div>
      <van-row>
        <van-col span="6">
          <p>0</p>
          <p>动态</p>
        </van-col>
        <van-col span="6">
          <p>0</p>
          <p>关注</p>
        </van-col>
        <van-col span="6">
          <p>0</p>
          <p>粉丝</p>
        </van-col>
        <van-col span="6">
          <p>0</p>
          <p>被赞</p>
        </van-col>
      </van-row>
      <van-row class="user-links">
        <van-col span="6">
          <geek-icon name="message"/>消息通知
        </van-col>
        <van-col span="6">
          <geek-icon name="mycollect"/>我的收藏
        </van-col>
        <van-col span="6">
          <geek-icon name="history"/>阅读历史
        </van-col>
        <van-col span="6">
          <geek-icon name="myworks"/>我的作品
        </van-col>
      </van-row>
    </div>
    <div class="more">
      <p>更多服务</p>
      <van-row>
        <van-col span="6">
          <geek-icon name="feedback2"/>用户反馈
        </van-col>
        <van-col span="6">
          <geek-icon  @click.native="$router.push('/user/chat')" name="xiaozhi"/>小智同学
        </van-col>
        <van-col span="6">
        </van-col>
        <van-col span="6">
        </van-col>
      </van-row>
    </div>
  </div>
</template>
<script>
export default {
  name: 'UserPage'
}
</script>
<style scoped lang='less'></style>
.user-page {
  background: #fafafa;
  height: 100%;
}
.more {
  margin: 0 15px;
  background-color: #fff;
  border-radius: 10px;
  height: 120px;
  p {
    font-size: 16px;
    padding: 10px 15px;
    margin: 0;
  }
  .van-row {
    padding: 15px 0;
    font-size: 12px;
    text-align: center;
    .geek-icon {
      display: block;
      font-size: 22px;
      padding-bottom: 5px;
    }
  }
}
.user {
  &-profile {
    width: 100%;
    padding: 0 15px;
    height: 240px;
    display: block;
    background: linear-gradient(318deg,#B2B5DB 0%,#565482 70%,#494675 100%);
    color: #fff;
    border-bottom-left-radius: 400px 60px;
    border-bottom-right-radius: 400px 60px;
    margin-bottom: 48px;
    .info {
      display: flex;
      padding: 40px 0;
      height: 130px;
      align-items: center;
      .van-image {
        width: 50px;
        height: 50px;
        border: 3px solid #7674a2;
      }
      .name {
        font-size: 20px;
        font-weight: normal;
        margin-left: 10px;
        flex: 1;
      }
      .btn {
        color: #fff;
        line-height: 1;
        font-size: 12px;
        .van-icon {
          position: relative;
          top: 1px;
        }
      }
    }
    p {
      margin: 0;
      text-align: center;
      height: 20px;
    }
  }
  &-links {
    padding: 20px 0;
    font-size: 12px;
    text-align: center;
    background-color: #fff;
    border-radius: 10px;
    color: #333;
    position: relative;
    top: 20px;
    .geek-icon {
      display: block;
      font-size: 22px;
      padding-bottom: 5px;
    }
  }
}
- 了解布局结构样式
 
1. info 作者信息
2. van-row 统计信息
3. van-row.user-links 用户信息相关链接
4. .more 更多服务
# 02-个人中心-渲染页面
目的:获取当前用户信息渲染页面
大致步骤:
- 定义API函数
 - 组件初始获取数据
 - 进行渲染
 
落地代码:
- 定义API函数 
src/api/user.js 
/**
 * 获取当前用户的信息(资料和统计)
 */
export const getUserInfo = () => {
  return request({ url: '/v1_0/user' })
}
- 个人中心组件初始化获取数据 
src/views/user/index.vue 
import { getUserInfo } from '@/api/user'
export default {
  name: 'UserPage',
  data () {
    return {
      user: {}
    }
  },
  created () {
    this.getUserInfo()
  },
  methods: {
    // 获取个人信息
    async getUserInfo () {
      const [, res] = await getUserInfo()
      this.user = res.data.data
    }
  }
}
- 进行渲染
 
      <div class="info">
        <van-image round fit="cover" :src="user.photo" />
        <h3 class="name">{{user.name}}</h3>
        <router-link class="btn" to="/user/profile">个人信息<van-icon name="arrow" /></router-link>
      </div>
      <van-row>
        <van-col span="6">
          <p>{{user.art_count}}</p>
          <p>动态</p>
        </van-col>
        <van-col span="6">
          <p>{{user.follow_count}}</p>
          <p>关注</p>
        </van-col>
        <van-col span="6">
          <p>{{user.fans_count}}</p>
          <p>粉丝</p>
        </van-col>
        <van-col span="6">
          <p>{{user.like_count}}</p>
          <p>被赞</p>
        </van-col>
      </van-row>
# 03-编辑资料-页面布局
目的:完成编辑资料页面布局,了解结构
 大致步骤:
- 配置路由组件
 - 根据笔记完成布局
 - 了解布局结构样式
 
落地代码:
- 组件 
src/views/user/profile/index.vue 
<template>
  <div class='user-profile-page'>
    <van-nav-bar left-arrow @click-left="$router.back()" title="个人信息"></van-nav-bar>
    <van-cell-group>
      <van-cell is-link title="头像" center>
        <van-image
          slot="default"
          fit="cover"
          round
          src="https://img01.yzcdn.cn/vant/cat.jpeg"
        />
      </van-cell>
      <van-cell is-link title="昵称" value="清风徐来" />
    </van-cell-group>
    <van-cell-group style="margin-top:12px">
      <van-cell is-link title="性别" value="男" />
      <van-cell is-link title="生日" value="2020-10-10" />
    </van-cell-group>
    <div class="logout">
      <span>退出登录</span>
    </div>
  </div>
</template>
<script>
export default {
  name: 'UserProfilePage'
}
</script>
<style lang="less" scoped>
.user-profile-page {
  background: #f8f8f8;
  .van-image {
    display: block;
    float: right;
    width: 30px;
    height: 30px;
  }
  .van-cell__title {
    width: 50px;
    flex: none;
  }
}
.logout {
  text-align: center;
  position: absolute;
  left: 0;
  bottom: 30px;
  width: 100%;
  color: @geek-color;
}
</style>
- 路由 
src/router/index.js 
const UserProfile = () => import('@/views/user/profile')
{ path: '/user/profile', component: UserProfile }
# 04-编辑资料-回显数据
目的,获取个人资料回显页面
大致步骤:
- 定义API函数
 - 组件初始获取数据
 - 进行渲染
 
落地代码:
- 定义API函数 
src/api/user.js 
/**
 * 获取当前用户的资料
 */
export const getUserProfile = () => {
  return request({ url: '/v1_0/user/profile' })
}
- 组件初始获取数据 
src/views/user/profile.vue 
import { getUserProfile } from '@/api/user'
export default {
  name: 'UserProfilePage',
  data () {
    return {
      user: {}
    }
  },
  created () {
    this.getUserProfile()
  },
  methods: {
    async getUserProfile () {
      const [, res] = await getUserProfile()
      this.user = res.data.data
    }
  }
}
- 进行渲染 
src/views/user/profile.vue 
    <van-cell-group>
+      <van-cell is-link title="头像" center>
        <van-image
          slot="default"
          fit="cover"
          round
          :src="user.photo"
        />
      </van-cell>
+      <van-cell is-link title="昵称" :value="user.name||'未填写'" />
    </van-cell-group>
    <van-cell-group style="margin-top:12px">
+      <van-cell is-link title="性别" :value="user.gender===0?'男':'女'" />
+      <van-cell is-link title="生日" :value="user.birthday||'未填写'" />
    </van-cell-group>
# 05-编辑资料-退出登录
目的:退出登录
大致步骤:
- 绑定事件
 - 处理函数,弹出确认框
 - 确认,删除token,跳转首页,提示退出
 
落地代码:
- 绑定事件
 
<span @click="logout">退出登录</span>
- 弹出确认框
 
    logout () {
      this.$dialog.confirm({
        title: '温馨提示',
        message: '您确认退出极客园吗?',
        theme: 'round-button'
      }).then(() => {
        this.$store.commit('user/setToken', '')
        this.$router.push('/')
        this.$toast.success('退出登录')
      }).catch(() => {})
    }
# 06-编辑资料-修改头像
目的:点击头像,弹出选项,点击本地上传,完成修改头像
大致步骤:
- 使用vant的动作面板组件,显示 本地上传 拍照 取消 选项
 - 调用本地上传,触发 input type="file" 的点击事件
 - 监听选择文件完成,调用封装号的API上传函数
 - 失败提示,成功(更新数据+提示)
 
大致步骤:
- api函数 
src/api/user.js 
/**
 * 修改头像
 * @param {Object} formData -  {photo:'文件数据'}
 */
export const updateUserPhoto = (formData) => {
  return request({
    url: '/v1_0/user/photo',
    method: 'patch',
    data: formData
  })
}
- 点击头像,显示动作面板
 
  data () {
    return {
      user: {},
+      // 修改头像相关数据
+      showPhoto: false
    }
  },
<van-cell is-link title="头像" center @click="showPhoto=true">
    <!-- 修改头像-弹出层 -->
    <van-action-sheet
      v-model="showPhoto"
      :actions="[{ name: '拍照', value: 0 },{ name: '本地选择', value: 1 }]"
      @select="onPhotoSelect"
      cancel-text="取消"
    />
- 点击本地上传,调用file输入框
 
    <input @change="updatePhoto" type="file" ref="file" style="display:none">
    // 选择修改头像-动作面板选项
    onPhotoSelect (item) {
      if (item.value === 1) {
        // 本地选择
        this.$refs.file.click()
        this.showPhoto = false
      }
    },
- 选择文件后完成上传
 
    // 修改头像
    async updatePhoto () {
      // 选择图片后的文件信息对象
      const file = this.$refs.file.files[0]
      // 打开资源管理器,不选选图片,点击取消 file 是空的
      if (file) {
        // 上传图片
        // 1. 包装一个formData对象,字段名字photo指向的是选择的图片
        const formData = new FormData()
        formData.append('photo', file)
        // 2. 调用API接口
        const [err, res] = await updateUserPhoto(formData)
        if (err) return this.$toast.fail('修改失败')
        // 3. 显示上传成功的头像,成功提示
        this.user.photo = res.data.data.photo
        this.$toast.success('修改成功')
      }
    },
# 07-编辑资料-修改昵称
目的:调用弹出框修改昵称
落地代码:
- 准备弹出框
 - 定义API函数
 - 完成保存
 
落地代码:
- 准备弹出框 
src/views/user/profile/index.vue 
  data () {
    return {
      user: {},
      // 修改头像相关数据
      showPhoto: false,
+      // 修改用户名称
+      showName: false,
+      name: ''
    }
  },
<van-cell is-link title="昵称" @click="openNamePopup()" :value="user.name||'未填写'" />
    // 打开修改名称弹出框
    openNamePopup () {
      this.showName = true
      this.name = this.user.name
    },
    <!-- 修改昵称 -->
    <van-popup class="my-popup" v-model="showName" position="right">
      <van-nav-bar
        left-arrow
        @click-left="showName=false"
        right-text="保存"
        title="修改昵称"
      />
      <van-field v-model="name"></van-field>
    </van-popup>
.my-popup {
  width: 100%;
  height: 100%;
  /deep/ .van-nav-bar__text {
    color: @geek-color;
  }
  /deep/ .van-field__control {
    background: @geek-gray-color;
    padding: 10px;
    border-radius: 4px;
  }
}
- API函数 
src/api/user.js 
/**
 * 修改用户
 * @param {Object} user - 用户对象
 */
export const updateUser = (user) => {
  return request({
    url: '/v1_0/user/profile',
    method: 'patch',
    data: user
  })
}
- 完成保存  
src/views/user/profile/index.vue 
      <van-nav-bar
        left-arrow
        @click-left="showName=false"
+        @click-right="saveName()"
        right-text="保存"
        title="修改昵称"
      />
    // 保存昵称
    async saveName () {
      if (!this.name) return this.$toast('请输入昵称')
      const [err] = await updateUserProfile({ name: this.name })
      if (err) return this.$toast.fail('更新失败')
      // 更新单元格中的昵称
      this.user.name = this.name
      this.showName = false
      this.$toast.success('更新成功')
    },
# 08-编辑资料-修改性别
目的:完成修改性别
大致步骤:
- 准备动作面板
 - 选择后保存
 
大致步骤:
- 准备动作面板
 
  data () {
    return {
      user: {},
      // 修改头像相关数据
      showPhoto: false,
      // 修改用户名称
      showName: false,
      name: '',
+      // 修改用户性别
+      showGender: false
    }
  },
    <!-- 修改性别 -->
    <van-action-sheet
      v-model="showGender"
      :actions="[{ name: '男', value: 0 }, { name: '女', value: 1 }]"
      @select="saveGender"
      cancel-text="取消"
    />
<van-cell is-link title="性别" @click="showGender=true" :value="user.gender===0?'男':'女'" />
- 选择后保存
 
    // 修改用户性别
    async saveGender (item) {
      const [err] = await updateUser({ gender: item.value })
      if (err) return this.$toast.fail('更新失败')
      this.user.gender = item.value
      this.showGender = false
      this.$toast.success('更新成功')
    },
# 09-编辑资料-修改生日
目的:完成修改生日
大致步骤:
- 准备日期选择器组件
 - 确认时间后保存生日
 
落地代码:
- 准备日期选择器组件
 
  data () {
    return {
      user: {},
      // 修改头像相关数据
      showPhoto: false,
      // 修改用户名称
      showName: false,
      name: '',
      // 修改用户性别
      showGender: false,
+      // 修改生日
+      showBirthday: false,
+      birthday: new Date(),
+      minDate: new Date('1960-01-01'),
+      maxDate: new Date()
    }
  },
    <!-- 修改生日 -->
    <van-popup v-model="showBirthday" position="bottom">
      <van-datetime-picker
        v-model="birthday"
        type="date"
        title="选择年月日"
        :min-date="minDate"
        :max-date="maxDate"
        @cancel="showBirthday=false"
        @confirm="saveBirthday"
      />
    </van-popup>
<van-cell is-link title="生日" @click="openBirthdayPopup" :value="user.birthday||'未填写'" />
    // 打开日期选择器
    openBirthdayPopup () {
      this.showBirthday = true
      // 给日期控件赋值,当前用户的生日
      this.birthday = new Date(this.user.birthday)
    },
- 确认时间后保存生日
 
    // 修改用户生日
    async saveBirthday () {
      // 转换格式
      const date = dayjs(this.birthday).format('YYYY-MM-DD')
      const [err] = await updateUser({ birthday: date })
      if (err) return this.$toast.fail('更新失败')
      this.user.birthday = date
      this.showBirthday = false
      this.$toast.success('更新成功')
    },
# 10-小智同学-基础布局
目的:搭建小智同学的基础布局
 大致步骤:
- 完成布局
 - 了解结构
 - 准备用户头像数据
 
落地代码:src/views/user/chat.vue
<template>
  <div class="user-chat-page">
    <van-nav-bar
      fixed
      left-arrow
      @click-left="$router.back()"
      title="小智同学"
    ></van-nav-bar>
    <!-- 聊天列表 -->
    <div class="chat-list" ref="list">
      <div class="chat-item left">
        <geek-icon name="xiaozhi" />
        <div class="chat-pao">我是小智同学,需要什么帮助吗?</div>
      </div>
      <div class="chat-item right">
        <div class="chat-pao">报名前端</div>
        <van-image fit="cover" round :src="myAvatar" />
      </div>
    </div>
    <!-- 输入框 -->
    <div class="reply-container van-hairline--top">
      <van-field
        v-model.trim="value"
        left-icon="edit"
        placeholder="请描述您的问题"
      ></van-field>
      <span class="send">发送</span>
    </div>
  </div>
</template>
<script>
import { getUserProfile } from '@/api/user'
export default {
  name: 'UserChatPage',
  data () {
    return {
      myAvatar: '',
      // list: [
      //   { name: 'xz', msg: '我是小智同学,需要什么帮助吗?' },
      //   { name: 'my', msg: '报名前端' }
      // ],
      value: ''
    }
  },
  async created () {
    const [, res] = await getUserProfile()
    this.myAvatar = res.data.data.photo
  }
}
</script>
<style lang="less" scoped>
.user-chat-page {
  height: 100%;
  width: 100%;
  position: absolute;
  left: 0;
  top: 0;
  box-sizing: border-box;
  background:#fff;
  padding: 46px 0 50px 0;
  .chat-list {
    height: 100%;
    overflow-y: scroll;
    .chat-item{
      padding: 10px;
      .van-image{
        vertical-align: middle;
        width: 40px;
        height: 40px;
      }
      .geek-icon {
        font-size: 40px;
        line-height: 0;
      }
      .chat-pao{
        vertical-align: top;
        display: inline-block;
        min-width: 40px;
        max-width: 70%;
        min-height: 40px;
        line-height: 20px;
        border-radius: 4px;
        position: relative;
        padding: 10px;
        background-color: @geek-gray-color;
        word-break: break-all;
        font-size: 14px;
        color: #333;
        &::before{
          content: "";
          width: 6px;
          height: 6px;
          position: absolute;
          top: 15px;
          background:  @geek-gray-color;
        }
      }
    }
  }
}
.chat-item.right{
  text-align: right;
  .chat-pao{
    margin-left: 0;
    margin-right: 10px;
    &::before{
      right: -3px;
      transform: rotate(45deg);
    }
  }
}
.chat-item.left{
  text-align: left;
  .chat-pao{
    margin-left: 10px;
    margin-right: 0;
    &::before{
      left: -3px;
      transform: rotate(-135deg);
    }
  }
}
.reply-container {
  position: fixed;
  left: 0;
  bottom: 0;
  height: 49px;
  width: 100%;
  background: #fff;
  z-index: 9999;
  display: flex;
  align-items: center;
  padding: 0 10px;
  > .van-field {
    flex: 1;
    background: #F7F8FA;
    height: 32px;
    border-radius: 16px;
    padding: 0 10px;
    line-height: 32px;
    ::v-deep .van-field__left-icon .van-icon {
      color: #ccc;
    }
  }
  > .send {
    margin-left: 10px;
    font-size: 14px;
    color: #999;
  }
}
</style>
# 11-小智同学-认识websocket
目的:认识websocket https://websocket.org/
什么是 websocket ?
- 是一种网络通信协议,和 HTTP 协议 一样。
 
为什么需要websocket ?
- 因为 HTTP 协议有一个缺陷:通信只能由客户端发起。
 
理解 websokect 通讯过程

了解 websocket 代码含义 https://websocket.org/echo.html http://jsbin.com/muqamiqimu/edit?js,console
// 创建ws实例,建立连接
var ws = new WebSocket("wss://echo.websocket.org");
// 连接成功事件
ws.onopen = function(evt) { 
  console.log("Connection open ...");
  // 发送消息
  ws.send("Hello WebSockets!");
};
// 接受消息事件
ws.onmessage = function(evt) {
  console.log( "Received Message: " + evt.data);
  // 关闭连接  
  ws.close();
};
// 关闭连接事件
ws.onclose = function(evt) {
  console.log("Connection closed.");
};      
我们项目中使用 socket.io-client 来实现客户端代码,它是基于 websocket 的库。
# 12-小智同学-完成聊天
目标:使用socket.io-client完成聊天
大致步骤:
- 先了解下socket.io-client的使用
 - 在组件中实现聊天
 
落地代码:
1) 先了解下socket.io-client的使用
- 仓库地址 https://github.com/socketio/socket.io-client
 - 官方文档 https://socket.io/ https://socketio.bootcss.com/docs/
 - 安装 
npm i socket.io-client 
import socketIO from 'socket.io-client'
const io = socketIO('http:localhost:8080')
// 建立连接
io.on('connect', ()=>{ 
  console.log('建立连接') 
  // 发送消息  
  io.emit('message', {msg:'你好'})  
})
// 接受消息:data 是后台发的数据
io.on('message', (data)=>{
  console.log('收到消息')
  // 关闭连接
  io.close()  
})
2) 在组件中实现聊天
- 完成列表渲染
 
  data () {
    return {
      myAvatar: '',
+      list: [
+         { name: 'xz', msg: '我是小智同学,需要什么帮助吗?' },
+         { name: 'my', msg: '报名前端' }
+      ],
      value: ''
    }
  },
    <!-- 聊天列表 -->
    <div class="chat-list" ref="list">
      <div class="chat-item"
        v-for="(item,i) in list"
        :key="i"
        :class="{left:item.name==='xz',right:item.name==='my'}"
      >
        <geek-icon v-if="item.name==='xz'" name="xiaozhi" />
        <div class="chat-pao">{{item.msg}}</div>
        <van-image v-if="item.name==='my'" fit="cover" round :src="myAvatar" />
      </div>
    </div>
- 开始聊天
 
<span class="send" @click="send()">发送</span>
import { getUserProfile } from '@/api/user'
import soekctIO from 'socket.io-client'
import { baseURL } from '@/utils/request'
export default {
  name: 'UserChatPage',
  data () {
    return {
      myAvatar: '',
      list: [
        // { name: 'xz', msg: '我是小智同学,需要什么帮助吗?' },
        // { name: 'my', msg: '我要学前端' }
      ],
      value: ''
    }
  },
  async created () {
    const [, res] = await getUserProfile()
    this.myAvatar = res.data.data.photo
    // 1. 建立连接
    this.io = soekctIO(baseURL, {
      query: {
        token: this.$store.state.user.token
      },
      transports: ['websocket']
    })
    // 2. 连接成功
    this.io.on('connect', () => {
      this.list.push({ name: 'xz', msg: '我是小智同学,需要什么帮助吗?' })
    })
    // 3. 接收消息
    this.io.on('message', data => {
      this.list.push({ name: 'xz', msg: data.msg })
    })
  },
  beforeDestroy () {
    // 4. 关闭连接
    this.io.close()
  },
  methods: {
    send () {
      if (!this.value) return this.$toast('请输入内容')
      // 发送消息
      this.io.emit('message', { msg: this.value, timestamp: Date.now() })
      this.list.push({ name: 'my', msg: this.value })
      this.value = ''
    }
  }
}
3)滚动底部:
$nextTick  等DOM更新完毕执行下一件事情
    // 4. 接收消息
    this.io.on('message', data => {
      this.list.push({ name: 'xz', msg: data.msg })
+      this.scrollBottom()
    })
  },
  beforeDestroy () {
    // 5. 断开连接
    this.io.close()
  },
  methods: {
    // 3. 发送消息
    send () {
      if (!this.value) return this.$toast('请输入内容')
      // 发消息
      this.io.emit('message', { msg: this.value, timestamp: Date.now() })
      // 存聊天记录
      this.list.push({ name: 'my', msg: this.value })
      this.value = ''
+      this.scrollBottom()
    },
+    scrollBottom () {
+      this.$nextTick(() => {
+        // 思路:滚动的距离 = 可滚动的高度 - 自身高度
+        const scrollHeight = this.$refs.list.scrollHeight
+        const offsetHeight = this.$refs.list.offsetHeight
+        this.$refs.list.scrollTop = scrollHeight - offsetHeight
+      })
+    }
  }
# 第九章:项目收尾
# 01-刷新token
目的:无感延长token有效期
大致步骤:
- 登录完毕后台返回:token 1-2小时 refresh_token 30天,利用它可以让登录延续30天。
 - 理解使用 refresh_token 过程
- vuex 本地存储 需要记录 refresh_token
 - 当你发请求的时候出现401,响应拦截器中
 - 拿着refresh_token向后台获取新的有效的token
 - 把本地的token更新成新的token
 - 继续发送之前失败的 请求 需要返回promise
 - 如果你用 refresh_token 去获取token的时候失败了,无效了,去登录
 
 
落地代码:
- 存储refresh_token 
src/store/modules/user.js 
// 用户模块共享数据管理
export default {
  namespaced: true,
  // 状态
  state () {
    return {
      // 令牌  初始化从本地获取
      token: localStorage.getItem('geek-client-mobile-token'),
+      refreshToken: localStorage.getItem('geek-client-mobile-refreshToken')
    }
  },
  // 修改
  mutations: {
    setToken (state, token) {
      // 1. 修改vuex的数据
      state.token = token
      // 2. 同步修改本地
      localStorage.setItem('geek-client-mobile-token', token)
    },
+    setRefreshToken (state, refreshToken) {
+      // 1. 修改vuex的数据
+      state.refreshToken = refreshToken
+      // 2. 同步修改本地
+      localStorage.setItem('geek-client-mobile-refreshToken', refreshToken)
+    }
  }
}
- 刷新token的API  
src/api/user.js 
import request, { baseURL } from '@/utils/request'
import axios from 'axios'
/**
 * 刷新token
 * @param {String} refreshToken - 保存的refresh_token
 * @returns
 */
export const refreshTokenAPI = (refreshToken) => {
  return axios({
    url: baseURL + 'v1_0/authorizations',
    method: 'put',
    headers: {
      Authorization: `Bearer ${refreshToken}`
    }
  })
}
- 响应拦截器,调用API 
src/utils/request.js 
import { refreshTokenAPI } from '@/api/user'
// 4. token失效处理
+instance.interceptors.response.use(res => res, async err => {
  // 1. 判断状态码401
  // 2. 删除token
  // 3. 跳转登录,当前路由的完整地址需要传递给登录页面。因为:将来登录完事需要回跳
  if (err.response && err.response.status === 401) {
+    // ★1. 使用refresh_token更新token不能使用instance,请求拦截器中会覆盖请求头
+    const [err2, res] = await to(refreshTokenAPI(store.state.user.refreshToken))
+    // ★2. 刷新token失败
+    if (err2) {
+      store.commit('user/setToken', '')
+      // 组件:this.$route.path 路径  fullPath 完整路径,带参数的
+      // 通过 router实例 获取当前路由信息对象 currentRoute
+      // 回跳地址:/order?id=100&name=tom  ===> 跳转登录地址  /login?returnUrl=/order?id=100&name=tom
+      // 地址可能回出现特殊字符 & 需要转换成url编码 encodeURIComponent
+      // 转义后 /login?returnUrl=%2Forder%3Fid%3D100%26name%3Dtom
+      router.push('/login?returnUrl=' + encodeURIComponent(router.currentRoute.fullPath))
+    } else {
+      // ★3. 刷新token成功
+      store.commit('user/setToken', res.data.data.token)
+      // ★4. 继续发送失败的请求,需要成功
+      // err.config 是之前失败的请求配置
+      return instance(err.config)
+    }
  }
  return Promise.reject(err)
})
- 推出登录 
src/views/user/profile/index.vue 
      }).then(() => {
        // 确认后退出
        this.$store.commit('user/setToken', '')
+        this.$store.commit('user/setRefreshToken', '')
        this.$router.push('/')
      }).catch(e => {})
# 02-打包部署
目的:打包之后部署至服务器
大致步骤:
- 修改打包后资源引用为相对路径
 - 执行 npm run build 打包出资源
 - 上传到服务器,使用node托管
 
落地内容:
- 修改打包后资源引用为相对路径
 
// vue-cli的配置文件
module.exports = {
+  publicPath: './',
  css: {
    loaderOptions: {
      less: {
        // less 的全局变量
        globalVars: {
          'geek-color': '#FC6627',
          'geek-gray-color': '#F7F8FA'
        }
      }
    }
  }
}
- 执行 npm run build 打包出资源
 

- 上传到服务器,使用node托管
- 将来工作中一般只需要把你打包的资源给后台或者运维即可,托管的服务器也不一定是node。
 
 
# 03-项目总结
目的:掌握项目技术解决方案,业务解决方案。
- 能够使用vant组件库构建移动界面
 - 能够使用 postcss-px-to-viewport 完成移动端适配
 - 能够使用 命名视图 完成页面局部内容复用
 - 能够使用 svg 方式的多色字体图标
 - 能够封装 request 请求工具
 - 能够使用 await-to-js 处理await代码异常
 - 能够使用 token 完成用户鉴权
 - 能够实现 移动端 上拉加载下拉刷新效果
 - 能够实现 移动端 骨架效果
 - 能够使用 dayjs 处理时间
 - 能够使用 keep-alive 针对某组件缓存
 - 能够实现 列表滚动位置记忆功能
 - 能够使用 highlight.js 对富文本代码 高亮功能
 - 能够实现 本地与线上 频道管理
 - 能够实现 评论与回复 功能
 - 能够使用 socket.io-client 完成双向通讯
 - 能够掌握 用户无感 刷新token