# 极客园M端

# 第一章:项目起步

# 01-项目介绍

目标:了解项目背景,了解项目功能。

项目背景:

  • 它对标 CSDN 博客园 等竞品,致力成为全球知名的IT技术交流平台,它包含 技术文章,问答内容,视频解答 的专业IT资讯平台,它提供原创,优质,完整内容的专业IT社区。它是 极客园 IT资讯社区。

项目功能:

  • 极客园-个人端M 是一款移动web应用。
  • 主要功能有:
    • 首页-文章频道,文章列表,更多操作
    • 详情-文章详情,文章评论,评论回复,点赞,收藏,关注
    • 登录-短信登录
    • 个人-信息展示,信息编辑

项目物料:http://geek.itheima.net

总结: 我们知道项目的大致功能即可。

# 02-使用技术

目标:了解使用技术

开发依赖大致如下:

  • 基础环境:nodejs12+ vscode vuecli4.x
  • 配套工具:eslint babel less
  • 使用技术:vue2.6.12 vue-router vue-vuex vant iconfont dayjs socket.io-client postcss-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
  • 选中自定义创建

1622440144872

  • 选择Vue版本,依赖Babel降级ES6语法,依赖vue-router,依赖vuex,使用css预处理器,使用代码风格校验。

1622440310596

  • 选择vue2.0版本

1622440660281

  • 是否使用历史模式API,输入 n

1622440752792

  • 选择less这种css预处理器

1622440811661

  • 选择 通用语法风格配置

1622440877508

  • 语法风格校验的时机,保存代码校验,提交代码校验且自动修复。

1622441060425

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

1622441142876

  • 是否记录此次操作记录,输入 n

1622441203780

  • 最后等待安装即可,安装完毕进入项目目录,执行 npm run serve 即可启动项目。

1622443003736

总结: 我们可以使用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>

1622513783623

总结: 如果后续不做打包优化 自动按需引入 推荐,但是我们为了开发方便后续也会做打包优化使用 全部引入 方式。

# 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的适配方案

大致步骤:

落地代码:

  • 安装
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,可以指定名称。
  • 使用场景在哪里?
    • 一个路由规则,可以通过命名视图,指定多个组件,组件是同级的,而不是嵌套关系。

落地分析:

  • 嵌套视图:

1622534525175

  • 命名视图:

1622534918577

总结: 在不使用嵌套视图情况下,可以使用命名视图来复用公用的布局内容。

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

1622601688139

总结: 把字体图标库的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实例,配置 baseURL timeout
  • 导出一个通过新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 写法。

落地代码:

    1. 演示 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 的代码块,代码层次增多,不好阅读。

    1. 使用 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函数。

    1. 优化 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秒倒计时,不可再次发送

落地代码:

  1. 准备 发送验证码 按钮 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;
  }
  1. 编写发送短信API接口 src/api/index.js
/**
 * 发送短信验证码
 * @param {String} mobile - 手机号
 * @returns Promise
 */
export const sendMessage = (mobile) => {
  return request({
    url: `/v1_0/sms/codes/${mobile}`
  })
}
  1. 点击 发送验证码 按钮,校验手机号,调用发送短信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 观察组件是否缓存

1624590995437

看见 inactive 代表当前组件被缓存,或者切换下组件看看组件数据状态是否保存住,还有组件被缓存了再次进入组件是不会触发 created 钩子的这也可以判断。

总结: 知道使用keep-alive组件缓存组件,知道使用include来制定被缓存组件。

# 08-首页-阅读位置

目的:组件缓存只会缓存状态数据,而滚动的位置(阅读位置)没有保持,我们需要完成阅读位置保持。

大致步骤:

  • 我们需要知道如何监听:进入组件(激活组件)
  • 我们需要在阅读列表的时候记录当前滚动位置
  • 我们需要在进入组件的时候还原之前滚动位置

落地代码:

src/views/home/components/article-list.vue

  • activateddeactivated 将会在 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

落地代码:

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-频道-支持本地操作

目的:未登录情况下获取我的频道都是写死的,服务端未提供修改接口,需要本地存储,本地修改。

1625817617182

大致步骤:

  • 按照流程图在 原来的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-频道-添加

目的:完成我的频道的添加操作,支持本地和线上。

1625819871732

大致步骤:

  • 按照流程图 定义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-频道-删除

目的:完成我的频道的删除操作,支持本地和线上。

1625821170546

大致步骤:

  • 按照流程图 定义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-文章详情-骨架效果

目的:在文章加载过程中加上骨架效果

1627201911906

大致步骤:

  • 定义一个加载中状态数据
  • 再请求前改成加载中,请求后改成加载完成
  • 根据数据显示骨架效果

落地代码: 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标签写的代码,给代码加上高亮样式

1627201877382

大致步骤:

  • 安装 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-文章评论-组件布局

目的:准备评论回复组件的基础布局结构,了解结构

1627201821802

大致步骤:

  • 准备基础布局结构
  • 使用组件
  • 分析了解布局结构

落地代码: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-文章评论-底部工具

目的:完成底部工具栏的布局和渲染。

1627201770541

大致步骤:

  • 完成底部工具栏布局,了解结构和样式
  • 使用 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-文章评论-传送功能

目的:评论按钮滚动到评论区,再次点击回到顶部。

1627202278994

大致步骤:

  • 分析:评论区位置 = 头部高度 + 内容高度 , 组件初始化就需要加载评论
  • 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-评论回复-回复弹层

目的:完成点击回复按钮,弹出回复页面,完成页面布局。

1627201611235

大致步骤:

  • 准备弹窗,点击回复切换

  • 完成头部,和底部

  • 完成回复列表

落地代码: 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-个人中心-基础布局

目的:完成个人中心首页基础布局

1627210383233

大致步骤:

  • 根据笔记完成布局
  • 了解布局结构样式

落地代码: 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-编辑资料-页面布局

目的:完成编辑资料页面布局,了解结构

1627219161998

大致步骤:

  • 配置路由组件
  • 根据笔记完成布局
  • 了解布局结构样式

落地代码:

  • 组件 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-小智同学-基础布局

目的:搭建小智同学的基础布局

1627227400490

大致步骤:

  • 完成布局
  • 了解结构
  • 准备用户头像数据

落地代码: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 通讯过程

img

了解 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 打包出资源

1627469626360

  • 上传到服务器,使用node托管
    • 将来工作中一般只需要把你打包的资源给后台或者运维即可,托管的服务器也不一定是node。

# 03-项目总结

目的:掌握项目技术解决方案,业务解决方案。

  • 能够使用vant组件库构建移动界面
  • 能够使用 postcss-px-to-viewport 完成移动端适配
  • 能够使用 命名视图 完成页面局部内容复用
  • 能够使用 svg 方式的多色字体图标
  • 能够封装 request 请求工具
  • 能够使用 await-to-js 处理await代码异常
  • 能够使用 token 完成用户鉴权
  • 能够实现 移动端 上拉加载下拉刷新效果
  • 能够实现 移动端 骨架效果
  • 能够使用 dayjs 处理时间
  • 能够使用 keep-alive 针对某组件缓存
  • 能够实现 列表滚动位置记忆功能
  • 能够使用 highlight.js 对富文本代码 高亮功能
  • 能够实现 本地与线上 频道管理
  • 能够实现 评论与回复 功能
  • 能够使用 socket.io-client 完成双向通讯
  • 能够掌握 用户无感 刷新token