1. 程式人生 > 其它 >L16-Vue-專案-黑馬頭條-L01-{ 專案初始化、登入註冊、個人中心 }

L16-Vue-專案-黑馬頭條-L01-{ 專案初始化、登入註冊、個人中心 }

開發文件 - 黑馬頭條

最新API介面

線上演示

http://toutiao.itheima.net/

介面文件

http://toutiao.itheima.net/api.html

檔案結構

D:.
│  App.vue
│  main.js
│
├─api
│      user.js
│
├─assets
├─components
├─router
│      index.js
│
├─store
│      index.js
│
├─styles
│      icon.css
│      icon.less
│      index.css
│      index.less
│
├─utils
│      request.js
│
└─views
    │  test.vue
    │
    └─login
            index.vue

一、專案初始化

目標

  • 能使用 Vue CLI 建立專案
  • 瞭解 Vant 元件庫的匯入方式
  • 掌握製作使用字型圖示的方式
  • 掌握如何在 Vue 專案中處理 REM 適配
  • 理解 axios 請求模組的封裝

使用 Vue CLI 建立專案

如果你還沒有安裝 VueCLI,請執行下面的命令安裝或是升級:

npm install --global @vue/cli

在命令列中輸入以下命令建立 Vue 專案:

vue create toutiao-m
Vue CLI v4.2.3
? Please pick a preset:
  default (babel, eslint)
> Manually select features

default:預設勾選 babel、eslint,回車之後直接進入裝包

manually:自定義勾選特性配置,選擇完畢之後,才會進入裝包

選擇第 2 種:手動選擇特性,支援更多自定義選項

? Please pick a preset: Manually select features
? Check the features needed for your project:
 (*) Babel
 ( ) TypeScript
 ( ) Progressive Web App (PWA) Support
 (*) Router
 (*) Vuex
 (*) CSS Pre-processors
>(*) Linter / Formatter
 ( ) Unit Testing
 ( ) E2E Testing

分別選擇:
Babel:es6 轉 es5
Router:路由
Vuex:資料容器,儲存共享資料
CSS Pre-processors:CSS 前處理器,後面會提示你選擇 less、sass、stylus 等
Linter / Formatter:程式碼格式校驗

? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) n

是否使用 history 路由模式,這裡輸入 n 不使用

? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default):
  Sass/SCSS (with dart-sass)
  Sass/SCSS (with node-sass)
> Less
  Stylus

選擇 CSS 前處理器,這裡選擇我們熟悉的 Less

? Pick a linter / formatter config:
  ESLint with error prevention only
  ESLint + Airbnb config
> ESLint + Standard config
  ESLint + Prettier

選擇校驗工具,這裡選擇 ESLint + Standard config

? Pick additional lint features:
 (*) Lint on save
>(*) Lint and fix on commit

選擇在什麼時機下觸發程式碼格式校驗:

  • Lint on save:每當儲存檔案的時候
  • Lint and fix on commit:每當執行 git commit 提交的時候

這裡建議兩個都選上,更嚴謹。

? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
> In dedicated config files
  In package.json

Babel、ESLint 等工具會有一些額外的配置檔案,這裡的意思是問你將這些工具相關的配置檔案寫到哪裡:

  • In dedicated config files:分別儲存到單獨的配置檔案
  • In package.json:儲存到 package.json 檔案中

這裡建議選擇第 1 個,儲存到單獨的配置檔案,這樣方便我們做自定義配置。

? Save this as a preset for future projects? (y/N) N

這裡裡是問你是否需要將剛才選擇的一系列配置儲存起來,然後它可以幫你記住上面的一系列選擇,以便下次直接重用。

這裡根據自己需要輸入 y 或者 n,我這裡輸入 n 不需要。

✨  Creating project in C:\Users\LPZ\Desktop\topline-m-fe89\topline-m-89.
�  Initializing git repository...
⚙  Installing CLI plugins. This might take a while...

[          ........] - extract:object-keys: sill extract [email protected]

嚮導配置結束,開始裝包。
安裝包的時間可能較長,請耐心等待......

⚓  Running completion hooks...

�  Generating README.md...

�  Successfully created project topline-m-89.
�  Get started with the following commands:

 $ cd topline-m
 $ npm run serve

安裝結束,命令提示你專案建立成功,按照命令列的提示在終端中分別輸入:

# 進入你的專案目錄
cd toutiao-webapp

# 啟動開發服務
npm run serve

 DONE  Compiled successfully in 7527ms


  App running at:
  - Local:   http://localhost:8080/
  - Network: http://192.168.10.216:8080/

  Note that the development build is not optimized.
  To create a production build, run npm run build.

啟動成功,命令列中輸出專案的 http 訪問地址。
開啟瀏覽器,輸入其中任何一個地址進行訪問。

如果能看到該頁面,恭喜你,專案建立成功了。

加入 Git 版本管理

幾個好處:

  • 程式碼備份
  • 多人協作
  • 歷史記錄
  • ...

(1)建立遠端倉庫

(2)將本地倉庫推到線上

如果沒有本地倉庫。

# 建立本地倉庫
git init

# 將檔案新增到暫存區
git add 檔案

# 提交歷史記錄
git commit "提交日誌"

# 新增遠端倉庫地址
git remote add origin 你的遠端倉庫地址

# 推送提交
git push -u origin master

如果已有本地倉庫(Vue CLI 已經幫我們初始化好了)。

# 新增遠端倉庫地址
git remote add origin 你的遠端倉庫地址

# 推送提交
git push -u origin master

如果之後專案程式碼有了變動需要提交:

git add
git commit
git push

調整初始目錄結構

預設生成的目錄結構不滿足我們的開發需求,所以這裡需要做一些自定義改動。

這裡主要就是下面的兩個工作:

  • 刪除初始化的預設檔案
  • 新增調整我們需要的目錄結構

1、將 App.vue 修改為

<template>
  <div id="app">
    <h1>黑馬頭條</h1>
    <router-view />
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style scoped lang="less"></style>

2、將 router/index.js 修改為

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const routes = [
]

const router = new VueRouter({
  routes
})

export default router

3、刪除

  • src/views/About.vue
  • src/views/Home.vue
  • src/components/HelloWorld.vue
  • src/assets/logo.png

4、建立以下幾個目錄

  • src/api 目錄
    • 儲存介面封裝
  • src/utils 目錄
    • 儲存一些工具模組
  • src/styles 目錄
    • index.less 檔案,儲存全域性樣式
    • main.js 中載入全域性樣式 import './styles/index.less'

調整之後的目錄結構如下。

.                                 
├── README.md                     
├── babel.config.js               
├── package-lock.json             
├── package.json                  
├── public                        
│   ├── favicon.ico               
│   └── index.html                
└── src                           
    ├── api
    ├── App.vue                   
    ├── assets                    
    ├── components                
    ├── main.js                   
    ├── router
    ├── utils
    ├── styles
    ├── store                     
    └── views

匯入圖示素材

設計師為我們單獨提供了設計稿中的圖示,為了方便使用,我們在這裡把它製作為字型圖示。

製作字型圖示的工具有很多,在這裡我們推薦大家使用:https://www.iconfont.cn/。

一、註冊賬戶

直接選擇第三方登入即可

二、建立專案

三、上傳圖示到專案

四、生成連結

五、配置到專案中使用

一種方式是將 SVG 圖示 包裝為 Vue 元件來使用

一種方式是將 SVG 製作為字型圖示來使用:

引入 Vant 元件庫

Vant 是有贊商城前端開發團隊開發的一個基於 Vue.js 的移動端元件庫,它提供了非常豐富的移動端功能元件,簡單易用。

下面是在 Vant 官網中列出的一些優點:

  • 60+ 高質量元件
  • 90% 單元測試覆蓋率
  • 完善的中英文文件和示例
  • 支援按需引入
  • 支援主題定製
  • 支援國際化
  • 支援 TS
  • 支援 SSR

在我們的專案中主要使用 Vant 作為核心元件庫,下面我們根據官方文件將 Vant 匯入專案中。

將 Vant 引入專案一共有四種方式:

  • 方式一:自動按需引入元件

    • 和方式二一樣,都是按需引入,但是載入更方便一些(需要額外配置外掛)
    • 優點:打包體積小
    • 缺點:每個元件在使用之前都需要手動載入註冊
  • 方式二:手動按需引入元件

    • 在不使用外掛的情況下,可以手動引入需要的元件
    • 優點:打包體積小
    • 缺點:每個元件在使用之前都需要手動載入註冊
  • 方式三:匯入所有元件

    • Vant 支援一次性匯入所有元件,引入所有元件會增加程式碼包體積,因此不推薦這種做法
    • 優點:匯入一次,使用所有
    • 缺點:打包體積大
  • 方式四:通過 CDN 引入

    • 使用 Vant 最簡單的方法是直接在 html 檔案中引入 CDN 連結,之後你可以通過全域性變數vant訪問到所有元件。
    • 優點:適合一些演示、示例專案,一個 html 檔案就可以跑起來
    • 缺點:不適合在模組化系統中使用

這裡建議為了前期開發的便利性我們選擇方式三:匯入所有元件,在最後做打包優化的時候根據需求配置按需載入以降低打包體積大小。

1、安裝 Vant

npm i vant

2、在 main.js 中載入註冊 Vant 元件

import Vue from 'vue'
import Vant from 'vant'
import 'vant/lib/index.css'

Vue.use(Vant)

3、查閱文件使用元件

Vant 的文件非常清晰,左側是元件目錄導航,中間是效果程式碼,右邊是效果預覽。

例如我們在根元件使用 Vant 中的元件:

<van-button type="default">預設按鈕</van-button>
<van-button type="primary">主要按鈕</van-button>
<van-button type="info">資訊按鈕</van-button>
<van-button type="warning">警告按鈕</van-button>
<van-button type="danger">危險按鈕</van-button>

<van-cell-group>
  <van-cell title="單元格" value="內容" />
  <van-cell title="單元格" value="內容" label="描述資訊" />
</van-cell-group>

如果在頁面中能夠正常的看到下面的效果,則說明 Vant 匯入成功了。

移動端 REM 適配

Vant 中的樣式預設使用 px 作為單位,如果需要使用 rem 單位,推薦使用以下兩個工具:

下面我們分別將這兩個工具配置到專案中完成 REM 適配。

一、使用 lib-flexible 動態設定 REM 基準值(html 標籤的字型大小)

1、安裝

# yarn add amfe-flexible
npm i amfe-flexible

2、然後在 main.js 中載入執行該模組

import 'amfe-flexible'

最後測試:在瀏覽器中切換不同的手機裝置尺寸,觀察 html 標籤 font-size 的變化。

例如在 iPhone 6/7/8 裝置下,html 標籤字型大小為 37.5 px

例如在 iPhone 6/7/8 Plus 裝置下,html 標籤字型大小為 41.4 px

二、使用 postcss-pxtorempx 轉為 rem

1、安裝

# yarn add -D postcss-pxtorem
# -D 是 --save-dev 的簡寫
npm install postcss-pxtorem -D

2、然後在專案根目錄中建立 .postcssrc.js 檔案

module.exports = {
  plugins: {
    'autoprefixer': {
      browsers: ['Android >= 4.0', 'iOS >= 8']
    },
    'postcss-pxtorem': {
      rootValue: 37.5,
      propList: ['*']
    }
  }
}

3、配置完畢,重新啟動服務

最後測試:重新整理瀏覽器頁面,審查元素的樣式檢視是否已將 px 轉換為 rem

這是沒有配置轉換之前的。

這是轉換之後的,可以看到 px 都被轉換為了 rem。

注意:該外掛不能轉換行內樣式中的 px,例如 <div style="width: 200px;"></div>

.postcssrc.js 配置檔案

module.exports = {
  plugins: {
    'autoprefixer': {
      browsers: ['Android >= 4.0', 'iOS >= 8']
    },
    'postcss-pxtorem': {
      rootValue: 37.5,
      propList: ['*']
    }
  }
}

.postcssrc.js 是 PostCSS 的配置檔案。

(1)PostCSS 介紹

PostCSS 是一個處理 CSS 的處理工具,本身功能比較單一,它主要負責解析 CSS 程式碼,再交由外掛來進行處理,它的外掛體系非常強大,所能進行的操作是多種多樣的,例如:

目前 PostCSS 已經有 200 多個功能各異的外掛。開發人員也可以根據專案的需要,開發出自己的 PostCSS 外掛。

PostCSS 一般不單獨使用,而是與已有的構建工具進行整合。

Vue CLI 預設集成了 PostCSS,並且預設開啟了 autoprefixer 外掛。

Vue CLI 內部使用了 PostCSS。

你可以通過 .postcssrc 或任何 postcss-load-config 支援的配置源來配置 PostCSS。也可以通過 vue.config.js 中的 css.loaderOptions.postcss 配置 postcss-loader

我們預設開啟了 autoprefixer。如果要配置目標瀏覽器,可使用 package.jsonbrowserslist 欄位。

(2)Autoprefixer 外掛的配置

autoprefixer 是一個自動新增瀏覽器字首的 PostCss 外掛,browsers 用來配置相容的瀏覽器版本資訊,但是寫在這裡的話會引起編譯器警告。

Replace Autoprefixer browsers option to Browserslist config.
Use browserslist key in package.json or .browserslistrc file.

Using browsers option can cause errors. Browserslist config
can be used for Babel, Autoprefixer, postcss-normalize and other tools.

If you really need to use option, rename it to overrideBrowserslist.

Learn more at:
https://github.com/browserslist/browserslist#readme
https://twitter.com/browserslist

警告意思就是說你應該將 browsers 選項寫到 package.json.browserlistrc 檔案中。

[Android]
>= 4.0

[iOS]
>= 8

具體語法請參考這裡

(3)postcss-pxtorem 外掛的配置

  • rootValue:表示根元素字型大小,它會根據根元素大小進行單位轉換
  • propList 用來設定可以從 px 轉為 rem 的屬性
    • 例如 * 就是所有屬性都要轉換,width 就是僅轉換 width 屬性

rootValue 應該如何設定呢?

如果你使用的是基於 lib-flexable 的 REM 適配方案,則應該設定為你的設計稿的十分之一。
例如設計稿是 750 寬,則應該設定為 75。

大多數設計稿的原型都是以 iphone6 為原型,iphone6 裝置的寬是 750,我們的設計稿也是這樣。

但是 Vant 建議設定為 37.5,為什麼呢?

因為 Vant 是基於 375 寫的,所以如果你設定為 75 的話,Vant 的樣式就小了一半。

所以如果設定為 37.5 的話,Vant 的樣式是沒有問題的,但是我們在測量設計稿的時候都必須除2才能使用,否則就會變得很大。

這樣做其實也沒有問題,但是有沒有更好的辦法呢?我就想實現測量多少寫多少(不用換算)。於是聰明的你就想,可以不可以這樣來做?

  • 如果是 Vant 的樣式,就把 rootValue 設定為 37.5 來轉換
  • 如果是我們的樣式,就按照 75 的 rootValue 來轉換

通過查閱文件我們可以看到 rootValue 支援兩種引數型別:

  • 數字:固定值
  • 函式:動態計算返回
    • postcss-pxtorem 處理每個 CSS 檔案的時候都會來呼叫這個函式
    • 它會把被處理的 CSS 檔案相關的資訊通過引數傳遞給該函式

所以我們修改配置如下:

/**
 * PostCSS 配置檔案
 */

module.exports = {
  // 配置要使用的 PostCSS 外掛
  plugins: {
    // 配置使用 autoprefixer 外掛
    // 作用:生成瀏覽器 CSS 樣式規則字首
    // VueCLI 內部已經配置了 autoprefixer 外掛
    // 所以又配置了一次,所以產生衝突了
    // 'autoprefixer': { // autoprefixer 外掛的配置
    //   // 配置要相容到的環境資訊
    //   browsers: ['Android >= 4.0', 'iOS >= 8']
    // },

    // 配置使用 postcss-pxtorem 外掛
    // 作用:把 px 轉為 rem
    'postcss-pxtorem': {
      rootValue ({ file }) {
        return file.indexOf('vant') !== -1 ? 37.5 : 75
      },
      propList: ['*']
    }
  }
}

配置完畢,把服務重啟一下,最後測試,very good。

封裝請求模組

和之前專案一樣,這裡我們還是使用 axios 作為我們專案中的請求庫,為了方便使用,我們把它封裝為一個請求模組,在需要的時候直接載入即可。

1、安裝 axios

npm i axios

2、建立 src/utils/request.js

/**
 * 封裝 axios 請求模組
 */
import axios from "axios"

const request = axios.create({
  baseURL: "http://ttapi.research.itcast.cn/" // 基礎路徑
})

export default request

3、如何使用

  • 方式一(簡單方便,但是不利於介面維護):我們可以把請求物件掛載到 Vue.prototype 原型物件中,然後在元件中通過 this.xxx 直接訪問
  • 方式二(推薦):我們把每一個請求都封裝成每個獨立的功能函式,在需要的時候載入呼叫,這種做法更便於介面的管理和維護

在我們的專案中建議使用方式二,更推薦(在隨後的業務功能中我們就能學到)。

二、登入註冊

目標

  • 能實現登入頁面的佈局
  • 能實現基本登入功能
  • 能掌握 Vant 中 Toast 提示元件的使用
  • 能理解 API 請求模組的封裝
  • 能理解發送驗證碼的實現思路
  • 能理解 Vant Form 實現表單驗證的使用

準備

建立元件並配置路由

1、建立 src/views/login/index.vue 並寫入以下內容

<template>
  <div class="login-container">登入頁面</div>
</template>

<script>
export default {
  name: 'LoginPage',
  components: {},
  props: {},
  data () {
    return {}
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {}
}
</script>

<style scoped lang="less"></style>

2、然後在 src/router/index.js 中配置登入頁的路由表

{
  path: '/login',
  name: 'login',
  component: () => import('@/views/login')
}

最後,訪問 /login 檢視是否能訪問到登入頁面。

佈局結構

這裡主要使用到三個 Vant 元件:

一個經驗:使用元件庫中的現有元件快速佈局,再慢慢調整細節,效率更高(剛開始可能會感覺有點麻煩,越用越熟,慢慢的就有了自己的思想)。

佈局樣式

寫樣式的原則:將公共樣式寫到全域性(src/styles/index.less),將區域性樣式寫到元件內部。

1、src/styles/index.less

body {
  background-color: #f5f7f9;
}

.page-nav-bar {
  background-color: #3296fa;
  .van-nav-bar__title {
    color: #fff;
  }
}

2、src/views/login/index.vue

<template>
  <div class="login-container">
    <!-- 導航欄 -->
    <van-nav-bar class="page-nav-bar" title="登入" />
    <!-- /導航欄 -->

    <!-- 登入表單 -->
    <van-form @submit="onSubmit">
      <van-field
        name="使用者名稱"
        placeholder="請輸入手機號"
      >
        <i slot="left-icon" class="toutiao toutiao-shouji"></i>
      </van-field>
      <van-field
        type="password"
        name="驗證碼"
        placeholder="請輸入驗證碼"
      >
        <i slot="left-icon" class="toutiao toutiao-yanzhengma"></i>
        <template #button>
          <van-button class="send-sms-btn" round size="small" type="default">傳送驗證碼</van-button>
        </template>
      </van-field>
      <div class="login-btn-wrap">
        <van-button class="login-btn" block type="info" native-type="submit">
          登入
        </van-button>
      </div>
    </van-form>
    <!-- /登入表單 -->
  </div>
</template>

<script>
export default {
  name: 'LoginIndex',
  components: {},
  props: {},
  data () {
    return {
    }
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {
    onSubmit (values) {
      console.log('submit', values)
    }
  }
}
</script>

<style scoped lang="less">
.login-container {
  .toutiao {
    font-size: 37px;
  }
  .send-sms-btn {
    width: 152px;
    height: 46px;
    line-height: 46px;
    background-color: #ededed;
    font-size: 22px;
    color: #666;
  }
  .login-btn-wrap {
    padding: 53px 33px;
    .login-btn {
      background-color: #6db4fb;
      border: none;
    }
  }
}
</style>

實現基本登入功能

思路:

  • 註冊點選登入的事件
  • 獲取表單資料(根據介面要求使用 v-model 繫結)
  • 表單驗證
  • 發請求提交
  • 根據請求結果做下一步處理

一、根據介面要求繫結獲取表單資料

1、在登入頁面元件的例項選項 data 中新增 user 資料欄位

...
data () {
  return {
    user: {
      mobile: '',
      code: ''
    }
  }
}

2、在表單中使用 v-model 繫結對應資料

<!-- van-cell-group 僅僅是提供了一個上下外邊框,能看到包裹的區域 -->
<van-cell-group>
  <van-field
    v-model="user.mobile"
    required
    clearable
    label="手機號"
    placeholder="請輸入手機號"
  />

  <van-field
    v-model="user.code"
    type="number"
    label="驗證碼"
    placeholder="請輸入驗證碼"
    required
  />
</van-cell-group>

最後測試。

一個小技巧:使用 VueDevtools 除錯工具檢視是否繫結成功。

二、請求登入

1、建立 src/api/user.js 封裝請求方法

/**
 * 使用者相關的請求模組
 */
import request from "@/utils/request"

/**
 * 使用者登入
 */
export const login = data => {
  return request({
    method: 'POST',
    url: '/app/v1_0/authorizations',
    data
  })
}

2、給登入按鈕註冊點選事件

async onLogin () {
  try {
    const res = await login(this.user)
    console.log('登入成功', res)
  } catch (err) {
    if (err.response.status === 400) {
     	console.log('登入失敗', err)
    }
  }
}

最後測試。

登入狀態提示

Vant 中內建了Toast 輕提示元件,可以實現移動端常見的提示效果。

// 簡單文字提示
Toast("提示內容");

// loading 轉圈圈提示
Toast.loading({
  duration: 0, // 持續展示 toast
  message: "載入中...",
  forbidClick: true // 是否禁止背景點選
});

// 成功提示
Toast.success("成功文案");

// 失敗提示
Toast.fail("失敗文案");

提示:在元件中可以直接通過 this.$toast 呼叫。

另外需要注意的是:Toast 預設採用單例模式,即同一時間只會存在一個 Toast,如果需要在同一時間彈出多個 Toast,可以參考下面的示例

Toast.allowMultiple();

const toast1 = Toast('第一個 Toast');
const toast2 = Toast.success('第二個 Toast');

toast1.clear();
toast2.clear();

下面是為我們的登入功能增加 toast 互動提示。

async onLogin () {
  // 開始轉圈圈
  this.$toast.loading({
    duration: 0, // 持續時間,0表示持續展示不停止
    forbidClick: true, // 是否禁止背景點選
    message: '登入中...' // 提示訊息
  })

  try {
    const res = await request({
      method: 'POST',
      url: '/app/v1_0/authorizations',
      data: this.user
    })
    console.log('登入成功', res)
    // 提示 success 或者 fail 的時候,會先把其它的 toast 先清除
    this.$toast.success('登入成功')
  } catch (err) {
    console.log('登入失敗', err)
    this.$toast.fail('登入失敗,手機號或驗證碼錯誤')
  }
}

假如請求非常快的話就看不到 loading 效果了,這裡可以手動將除錯工具中的網路設定為慢速網路。

測試結束,再把網路設定恢復為 Online 正常網路。

表單驗證

參考文件:Form 表單驗證

<template>
  <div class="login-container">
    <!-- 導航欄 -->
    <van-nav-bar class="page-nav-bar" title="登入" />
    <!-- /導航欄 -->

    <!-- 登入表單 -->
    <!--
      表單驗證:
        1、給 van-field 元件配置 rules 驗證規則
          參考文件:https://youzan.github.io/vant/#/zh-CN/form#rule-shu-ju-jie-gou
        2、當表單提交的時候會自動觸發表單驗證
           如果驗證通過,會觸發 submit 事件
           如果驗證失敗,不會觸發 submit
     -->
    <van-form @submit="onSubmit">
      <van-field
        v-model="user.mobile"
        name="手機號"
        placeholder="請輸入手機號"
+        :rules="userFormRules.mobile"
        type="number"
        maxlength="11"
      >
        <i slot="left-icon" class="toutiao toutiao-shouji"></i>
      </van-field>
      <van-field
        v-model="user.code"
        name="驗證碼"
        placeholder="請輸入驗證碼"
+        :rules="userFormRules.code"
        type="number"
        maxlength="6"
      >
        <i slot="left-icon" class="toutiao toutiao-yanzhengma"></i>
        <template #button>
          <van-button class="send-sms-btn" round size="small" type="default">傳送驗證碼</van-button>
        </template>
      </van-field>
      <div class="login-btn-wrap">
        <van-button class="login-btn" block type="info" native-type="submit">
          登入
        </van-button>
      </div>
    </van-form>
    <!-- /登入表單 -->
  </div>
</template>

<script>
import { login } from '@/api/user'

export default {
  name: 'LoginIndex',
  components: {},
  props: {},
  data () {
    return {
      user: {
        mobile: '', // 手機號
        code: '' // 驗證碼
      },
+      userFormRules: {
+        mobile: [{
+          required: true,
+          message: '手機號不能為空'
+        }, {
+          pattern: /^1[3|5|7|8]\d{9}$/,
+          message: '手機號格式錯誤'
+        }],
+        code: [{
+          required: true,
+          message: '驗證碼不能為空'
+        }, {
+          pattern: /^\d{6}$/,
+          message: '驗證碼格式錯誤'
+        }]
+      }
    }
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {
    async onSubmit () {
      // 1. 獲取表單資料
      const user = this.user

      // TODO: 2. 表單驗證

      // 3. 提交表單請求登入
      this.$toast.loading({
        message: '登入中...',
        forbidClick: true, // 禁用背景點選
        duration: 0 // 持續時間,預設 2000,0 表示持續展示不關閉
      })

      try {
        const res = await login(user)
        console.log('登入成功', res)
        this.$toast.success('登入成功')
      } catch (err) {
        if (err.response.status === 400) {
          this.$toast.fail('手機號或驗證碼錯誤')
        } else {
          this.$toast.fail('登入失敗,請稍後重試')
        }
      }

      // 4. 根據請求響應結果處理後續操作
    }
  }
}
</script>

<style scoped lang="less">
.login-container {
  .toutiao {
    font-size: 37px;
  }

  .send-sms-btn {
    width: 152px;
    height: 46px;
    line-height: 46px;
    background-color: #ededed;
    font-size: 22px;
    color: #666;
  }

  .login-btn-wrap {
    padding: 53px 33px;
    .login-btn {
      background-color: #6db4fb;
      border: none;
    }
  }
}
</style>

驗證碼處理

驗證手機號

async onSendSms () {
  console.log('onSendSms')
  // 1. 校驗手機號
  try {
    await this.$refs.loginForm.validate('mobile')
  } catch (err) {
    return console.log('驗證失敗', err)
  }

  // 2. 驗證通過,顯示倒計時
  // 3. 請求傳送驗證碼
}

使用倒計時元件

1、在 data 中新增資料用來控制倒計時的顯示和隱藏

data () {
  return {
    ...
    isCountDownShow: false
  }
}

2、使用倒計時元件

<van-field
  v-model="user.code"
  placeholder="請輸入驗證碼"
>
  <i class="icon icon-mima" slot="left-icon"></i>
  <van-count-down
    v-if="isCountDownShow"
    slot="button"
    :time="1000 * 5"
    format="ss s"
    @finish="isCountDownShow = false"
  />
  <van-button
    v-else
    slot="button"
    size="small"
    type="primary"
    round
    @click="onSendSmsCode"
  >傳送驗證碼</van-button>
</van-field>

請求介面,傳送驗證碼

1、在 api/user.js 中新增封裝資料介面

export const getSmsCode = mobile => {
  return request({
    method: 'GET',
    url: `/app/v1_0/sms/codes/${mobile}`
  })
}

2、給傳送驗證碼按鈕註冊點選事件

3、傳送處理

async onSendSms () {
  // 1. 校驗手機號
  try {
    await this.$refs.loginForm.validate('mobile')
  } catch (err) {
    return console.log('驗證失敗', err)
  }

  // 2. 驗證通過,顯示倒計時
  this.isCountDownShow = true

  // 3. 請求傳送驗證碼
  try {
    await sendSms(this.user.mobile)
    this.$toast('傳送成功')
  } catch (err) {
    // 傳送失敗,關閉倒計時
    this.isCountDownShow = false
    if (err.response.status === 429) {
      this.$toast('傳送太頻繁了,請稍後重試')
    } else {
      this.$toast('傳送失敗,請稍後重試')
    }
  }
}

處理使用者 Token

Token 是使用者登入成功之後服務端返回的一個身份令牌,在專案中的多個業務中需要使用到:

  • 訪問需要授權的 API 介面
  • 校驗頁面的訪問許可權
  • ...

但是我們只有在第一次使用者登入成功之後才能拿到 Token。

所以為了能在其它模組中獲取到 Token 資料,我們需要把它儲存到一個公共的位置,方便隨時取用。

往哪兒存?

  • 本地儲存
    • 獲取麻煩
    • 資料不是響應式
  • Vuex 容器(推薦)
    • 獲取方便
    • 響應式的

使用容器儲存 Token 的思路:

  • 登入成功,將 Token 儲存到 Vuex 容器中
    • 獲取方便
    • 響應式
  • 為了持久化,還需要把 Token 放到本地儲存
    • 持久化

下面是具體實現。

1、在 src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    // 使用者的登入狀態資訊
    user: JSON.parse(window.localStorage.getItem('TOUTIAO_USER'))
    // user: null
  },
  mutations: {
    setUser (state, user) {
      state.user = user
      window.localStorage.setItem('TOUTIAO_USER', JSON.stringify(user))
    }
  },
  actions: {
  },
  modules: {
  }
})

2、登入成功以後將後端返回的 token 相關資料儲存到容器中

async onLogin () {
  // const loginToast = this.$toast.loading({
  this.$toast.loading({
    duration: 0, // 持續時間,0表示持續展示不停止
    forbidClick: true, // 是否禁止背景點選
    message: '登入中...' // 提示訊息
  })

  try {
    const res = await login(this.user)

    // res.data.data => { token: 'xxx', refresh_token: 'xxx' }
+    this.$store.commit('setUser', res.data.data)

    // 提示 success 或者 fail 的時候,會先把其它的 toast 先清除
    this.$toast.success('登入成功')
  } catch (err) {
    console.log('登入失敗', err)
    this.$toast.fail('登入失敗,手機號或驗證碼錯誤')
  }

  // 停止 loading,它會把當前頁面中所有的 toast 都給清除
  // loginToast.clear()
}

優化封裝本地儲存操作模組

建立 src/utils/storage.js 模組。

export const getItem = name => {
  const data = window.localStorage.getItem(name)
  try {
    return JSON.parse(data)
  } catch (err) {
    return data
  }
}

export const setItem = (name, value) => {
  if (typeof value === 'object') {
    value = JSON.stringify(value)
  }
  window.localStorage.setItem(name, value)
}

export const removeItem = name => {
  window.localStorage.removeItem(name)
}

關於 Token 過期問題

登入成功之後後端會返回兩個 Token:

  • token:訪問令牌,有效期2小時
  • refresh_token:重新整理令牌,有效期14天,用於訪問令牌過期之後重新獲取新的訪問令牌

我們的專案介面中設定的 Token 有效期是 2 小時,超過有效期服務端會返回 401 表示 Token 無效或過期了。

為什麼過期時間這麼短?

  • 為了安全,例如 Token 被別人盜用

過期了怎麼辦?

  • 讓使用者重新登入,使用者體驗太差了
  • 使用 refresh_token 解決 token 過期

如何使用 refresh_token 解決 token 過期?

到課程的後面我們開發的業務功能豐富起來之後,再給大家講解 Token 過期處理。

大家需要注意的是在學習測試的時候如果收到 401 響應碼,請重新登入再測試

概述:伺服器生成token的過程中,會有兩個時間,一個是token失效時間,一個是token重新整理時間,重新整理時間肯定比失效時間長,當用戶的 token 過期時,你可以拿著過期的token去換取新的token,來保持使用者的登陸狀態,當然你這個過期token的過期時間必須在重新整理時間之內,如果超出了重新整理時間,那麼返回的依舊是 401。

處理流程:

  1. 在axios的攔截器中加入token重新整理邏輯
  2. 當用戶token過期時,去向伺服器請求新的 token
  3. 把舊的token替換為新的token
  4. 然後繼續使用者當前的請求

在請求的響應攔截器中統一處理 token 過期:

/**
 * 封裝 axios 請求模組
 */
import axios from "axios";
import jsonBig from "json-bigint";
import store from "@/store";
import router from "@/router";

// axios.create 方法:複製一個 axios
const request = axios.create({
  baseURL: "http://ttapi.research.itcast.cn/" // 基礎路徑
});

/**
 * 配置處理後端返回資料中超出 js 安全整數範圍問題
 */
request.defaults.transformResponse = [
  function(data) {
    try {
      return jsonBig.parse(data);
    } catch (err) {
      return {};
    }
  }
];

// 請求攔截器
request.interceptors.request.use(
  function(config) {
    const user = store.state.user;
    if (user) {
      config.headers.Authorization = `Bearer ${user.token}`;
    }
    // Do something before request is sent
    return config;
  },
  function(error) {
    // Do something with request error
    return Promise.reject(error);
  }
);

// 響應攔截器
request.interceptors.response.use(
  // 響應成功進入第1個函式
  // 該函式的引數是響應物件
  function(response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    return response;
  },
  // 響應失敗進入第2個函式,該函式的引數是錯誤物件
  async function(error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    // 如果響應碼是 401 ,則請求獲取新的 token

    // 響應攔截器中的 error 就是那個響應的錯誤物件
    console.dir(error);
    if (error.response && error.response.status === 401) {
      // 校驗是否有 refresh_token
      const user = store.state.user;

      if (!user || !user.refresh_token) {
        router.push("/login");

        // 程式碼不要往後執行了
        return;
      }

      // 如果有refresh_token,則請求獲取新的 token
      try {
        const res = await axios({
          method: "PUT",
          url: "http://ttapi.research.itcast.cn/app/v1_0/authorizations",
          headers: {
            Authorization: `Bearer ${user.refresh_token}`
          }
        });

        // 如果獲取成功,則把新的 token 更新到容器中
        console.log("重新整理 token  成功", res);
        store.commit("setUser", {
          token: res.data.data.token, // 最新獲取的可用 token
          refresh_token: user.refresh_token // 還是原來的 refresh_token
        });

        // 把之前失敗的使用者請求繼續發出去
        // config 是一個物件,其中包含本次失敗請求相關的那些配置資訊,例如 url、method 都有
        // return 把 request 的請求結果繼續返回給發請求的具體位置
        return request(error.config);
      } catch (err) {
        // 如果獲取失敗,直接跳轉 登入頁
        console.log("請求刷線 token 失敗", err);
        router.push("/login");
      }
    }

    return Promise.reject(error);
  }
);

export default request;

三、個人中心

TabBar 處理

通過分析頁面,我們可以看到,首頁、問答、視訊、我的 都使用的是同一個底部標籤欄,我們沒必要在每個頁面中都寫一個,所以為了通用方便,我們可以使用 Vue Router 的巢狀路由來處理。

  • 父路由:一個空頁面,包含一個 tabbar,中間留子路由出口
  • 子路由
    • 首頁
    • 問答
    • 視訊
    • 我的

一、建立 tabbar 元件並配置路由

這裡主要使用到的 Vant 元件:

1、建立 src/views/layout/index.vue

<template>
  <div class="layout-container">
    <!-- 子路由出口 -->
    <router-view />
    <!-- /子路由出口 -->

    <!-- 標籤導航欄 -->
    <!--
      route: 開啟路由模式
     -->
    <van-tabbar class="layout-tabbar" route>
      <van-tabbar-item to="/">
        <i slot="icon" class="toutiao toutiao-shouye"></i>
        <span class="text">首頁</span>
      </van-tabbar-item>
      <van-tabbar-item to="/qa">
        <i slot="icon" class="toutiao toutiao-wenda"></i>
        <span class="text">問答</span>
      </van-tabbar-item>
      <van-tabbar-item to="/video">
        <i slot="icon" class="toutiao toutiao-shipin"></i>
        <span class="text">視訊</span>
      </van-tabbar-item>
      <van-tabbar-item to="/my">
        <i slot="icon" class="toutiao toutiao-wode"></i>
        <span class="text">我的</span>
      </van-tabbar-item>
    </van-tabbar>
    <!-- /標籤導航欄 -->
  </div>
</template>

<script>
export default {
  name: 'LayoutIndex',
  components: {},
  props: {},
  data () {
    return {
    }
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {}
}
</script>

<style scoped lang="less">
.layout-container {
  .layout-tabbar {
    i.toutiao {
      font-size: 40px;
    }
    span.text {
      font-size: 20px;
    }
  }
}
</style>

2、然後將 layout 元件配置到一級路由

{
  path: '/',
  component: () => import('@/views/layout')
}

訪問 / 測試。

二、分別建立首頁、問答、視訊、我的頁面元件

首頁元件:

<template>
  <div class="home-container">首頁</div>
</template>

<script>
export default {
  name: 'HomePage',
  components: {},
  props: {},
  data () {
    return {}
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {}
}
</script>

<style scoped></style>

問答元件:

<template>
  <div class="qa-container">問答</div>
</template>

<script>
export default {
  name: 'QaPage',
  components: {},
  props: {},
  data () {
    return {}
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {}
}
</script>

<style scoped></style>

視訊元件:

<template>
  <div class="video-container">首頁</div>
</template>

<script>
export default {
  name: 'VideoPage',
  components: {},
  props: {},
  data () {
    return {}
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {}
}
</script>

<style scoped></style>

我的元件:

<template>
  <div class="my-container">首頁</div>
</template>

<script>
export default {
  name: 'MyPage',
  components: {},
  props: {},
  data () {
    return {}
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {}
}
</script>

<style scoped></style>

二、將四個主頁面配置為 tab-bar 的子路由

{
  path: '/',
  name: 'tab-bar',
  component: () => import('@/views/tab-bar'),
  children: [
    {
      path: '', // 預設子路由
      name: 'home',
      component: () => import('@/views/home')
    },
    {
      path: 'qa',
      name: 'qa',
      component: () => import('@/views/qa')
    },
    {
      path: 'video',
      name: 'video',
      component: () => import('@/views/video')
    },
    {
      path: 'my',
      name: 'my',
      component: () => import('@/views/my')
    }
  ]
}

最後測試。

頁面佈局

未登入頭部狀態

<template>
  <div class="my-container">
    <div class="header">
      <img
        class="mobile-img"
        src="~@/assets/mobile.png"
        @click="$router.push('/login')"
      >
    </div>
    <div class="grid-nav"></div>
    <van-cell title="訊息通知" is-link url="" />
    <van-cell title="實名認證" is-link url="" />
    <van-cell title="使用者反饋" is-link url="" />
    <van-cell title="小智同學" is-link url="" />
    <van-cell title="系統設定" is-link url="" />
  </div>
</template>

<script>
export default {
  name: 'MyIndex',
  components: {},
  props: {},
  data () {
    return {}
  },
  computed: {},
  watch: {},
  created () {},
  mounted () {},
  methods: {}
}
</script>

<style scoped lang="less">
.my-container {
  > .header {
    height: 361px;
    background: url("~@/assets/banner.png") no-repeat;
    background-size: cover;
    display: flex;
    justify-content: center;
    align-items: center;
    .mobile-img {
      width: 132px;
      height: 132px;
    }
  }
}
</style>

已登入頭部

宮格導航

單元格導航

處理已登入和未登入的頁面展示

  • 未登入,展示登入按鈕
  • 已登入,展示登入使用者資訊
<!-- 已登入:使用者資訊 -->
<div v-if="$store.state.user" class="user-info-wrap">
  ...
</div>
<!-- /已登入:使用者資訊 -->

<!-- 未登入 -->
<div v-else class="not-login" @click="$router.push('/login')">
  ...
</div>
<!-- /未登入 -->

<!-- 退出 -->
<van-cell-group v-if="$store.state.user">
  ...
</van-cell-group>
<!-- /退出 -->

使用者退出

1、給退出按鈕註冊點選事件

2、退出處理

onLogout () {
  // 退出提示
  // 在元件中需要使用 this.$dialog 來呼叫彈框元件
  this.$dialog.confirm({
    title: '確認退出嗎?'
  }).then(() => {
    // on confirm
    // 確認退出:清除登入狀態(容器中的 user + 本地儲存中的 user)
    this.$store.commit('setUser', null)
  }).catch(() => {
    // on cancel
    console.log('取消執行這裡')
  })
}

最後測試。

展示登入使用者資訊

步驟:

  • 封裝介面
  • 請求獲取資料
  • 模板繫結

1、在 api/user.js 中新增封裝資料介面

/**
 * 獲取使用者自己的資訊
 */
export const getUserInfo = () => {
  return request({
    method: 'GET',
    url: '/app/v1_0/user',
    // 傳送請求頭資料
    headers: {
      // 注意:該介面需要授權才能訪問
      //       token的資料格式:Bearer token資料,注意 Bearer 後面有個空格
      Authorization: `Bearer ${store.state.user.token}`
    }
  })
}

2、在 views/my/index.vue 請求載入資料

+ import { getUserInfo } from '@/api/user'

export default {
  name: 'MyPage',
  components: {},
  props: {},
  data () {
    return {
+      userInfo: {} // 使用者資訊
    }
  },
  computed: {},
  watch: {},
+++  created () {
    // 初始化的時候,如果使用者登入了,我才請求獲取當前登入使用者的資訊
    if (this.$store.state.user) {
      this.loadUser()
    }
  },
  mounted () {},
  methods: {
+++    async loadUser () {
      try {
        const { data } = await getUserInfo()
        this.user = data.data
      } catch (err) {
        console.log(err)
        this.$toast('獲取資料失敗')
      }
    }
  }
}

3、模板繫結

優化設定 Token

專案中的介面除了登入之外大多數都需要提供 token 才有訪問許可權。

通過介面文件可以看到,後端介面要求我們將 token 放到請求頭 Header 中並以下面的格式傳送。

欄位名稱:Authorization

欄位值:Bearer token,注意 Bearertoken 之間有一個空格

方式一:在每次請求的時候手動新增(麻煩)。

axios({
  method: "",
  url: "",
  headers: {
    Authorization: "Bearer token"
  }
})

方式二:使用請求攔截器統一新增(推薦,更方便)。

sequenceDiagram participant A as 發起請求 participant B as 請求攔截器 participant C as 服務端 A-->>B: http://xxx Note right of B: 設定 token B->>C: 請求發出

src/utils/request.js 中新增攔截器統一設定 token:

/**
 * 請求模組
 */
import axios from 'axios'
import store from '@/store'

const request = axios.create({
  baseURL: 'http://ttapi.research.itcast.cn/' // 介面的基準路徑
})

// 請求攔截器
// Add a request interceptor
request.interceptors.request.use(function (config) {
  // Do something before request is sent
  // config :本次請求的配置物件
  // config 裡面有一個屬性:headers
  const { user } = store.state
  if (user && user.token) {
    config.headers.Authorization = `Bearer ${user.token}`
  }
  return config
}, function (error) {
  // Do something with request error
  return Promise.reject(error)
})

// 響應攔截器

export default request
函式 引數 說明 歷史
appendTo node 追加到xxx 大人
神奇的動物園
appendTo node 追加到xxx 歷年
試卷
神奇
歷史