uni-app(优医咨询)项目实战 - 第3天

2024-04-20 11:09:05 浏览数 (3)

学习目标:

  • 掌握 luch-request 网络请求的用法
  • 能够对 Pinia 进行初始化操作
  • 掌握创建 Store 及数据操作的步骤
  • 能够对 Pinia 数据进行持久化的处理
  • 掌握用户登录的实现方法
一、项目启动

从零起步创建项目,完整的静态页面可以从 gitee 仓库获取。

1.1 创建项目

以 HBuilder X 的方式创建项目:

  • 项目名称:优医咨询
  • Vue 版本:Vue3
  • 模板:默认模板
1.1.1 .prettierrc

在项目根目录下创建 .prettierrc 文件,然后添加下述配置选项:

代码语言:javascript复制
{
  "printWidth": 80,
  "tabWidth": 2,
  "useTabs": false,
  "semi": false,
  "singleQuote": true,
  "vueIndentScriptAndStyle": true,
}

上述配置内容是关于 Prettier 的常用的配置项,以后实际开发过程中可以根据需要逐步完善。

1.1.2 配置 tabBar

根据设计稿的要求配置 tabBar,首先通过 HBuilder X 新建 3 个页面,然后再配置 pages.json 文件。

共有4个页面,分别为:首页、健康百科、消息通知、我的,在课堂上统一约束目录的名称:首页对应 index、健康百科对应 wiki、消息通知对应 notify、我的对应 my 。

tabBar 用的图片在课程资料中可以找到,将其拷贝到项目的根目录下,然后在 pages.json 中进行配置:

代码语言:javascript复制
{
  "pages": [{
    "path": "pages/index/index",
    "style": {
      "navigationBarTitleText": "优医咨询"
    }
  }, {
    "path": "pages/my/index",
    "style": {
      "navigationBarTitleText": "我的",
      "enablePullDownRefresh": false
    }

  }, {
    "path": "pages/notify/index",
    "style": {
      "navigationBarTitleText": "消息通知",
      "enablePullDownRefresh": false
    }

  }, {
    "path": "pages/wiki/index",
    "style": {
      "navigationBarTitleText": "健康百科",
      "enablePullDownRefresh": false
    }

  }],
  "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "优医咨询",
    "navigationBarBackgroundColor": "#fff",
    "backgroundColor": "#F8F8F8"
  },
  "tabBar": {
    "color": "#6F6F6F",
    "selectedColor": "#6F6F6F",
    "borderStyle": "white",
    "list": [{
        "text": "首页",
        "pagePath": "pages/index/index",
        "iconPath": "static/tabbar/home-default.png",
        "selectedIconPath": "static/tabbar/home-active.png"
      },
      {
        "text": "健康百科",
        "pagePath": "pages/wiki/index",
        "iconPath": "static/tabbar/wiki-default.png",
        "selectedIconPath": "static/tabbar/wiki-active.png"
      },
      {
        "text": "消息通知",
        "pagePath": "pages/notify/index",
        "iconPath": "static/tabbar/notify-default.png",
        "selectedIconPath": "static/tabbar/notify-active.png"
      },
      {
        "text": "我的",
        "pagePath": "pages/my/index",
        "iconPath": "static/tabbar/my-default.png",
        "selectedIconPath": "static/tabbar/my-active.png"
      }
    ]
  },
  "uniIdRouter": {}
}

除了配置 tabBar 外,还要配置每个页面的导航栏的标题 navigationBarTitleText 及全局导航栏背景颜色 navigationBarBackgroundColor 为白色。

1.1.3 公共样式

在 App.vue 中配置公共 css 代码,不仅能精简代码,将来样式的维护也会更方便,这些公共样式是由开发者根据不同的项目需要自定义的,因此不同的项目或者不同开发者定义的公共样式是不一致的,本项目中我定义了以下部分的公共样式:

代码语言:javascript复制
<!-- App.vue -->
<script>
	// 省略这里的代码...
</script>

<style lang="scss">
  image {
    vertical-align: middle;
  }

  button:after {
    display: none;
  }

  .uni-button {
    height: 88rpx;
    text-align: center;
    line-height: 88rpx;
    border-radius: 88rpx;
    color: #fff;
    font-size: 32rpx;
    background-color: #20c6b2;

    &[disabled],
    &.disabled {
      color: #fff !important;
      background-color: #ace8e0 !important;
    }
  }
</style>

关于 scss 本项目定义了一个变量和一个混入,这个混入是用来处理文字溢出的,溢出的部分会显示 ... 来代替。

代码语言:javascript复制
// uni.scss

// 省略了默认生成的 scss 代码...

$line: 2;
@mixin text-overflow($line) {
  display: -webkit-box;
  -webkit-line-clamp: $line;
  -webkit-box-orient: vertical;
  text-overflow: ellipsis;
  overflow: hidden;
}
1.1.4 引入字体图标

项目中即用到了单色图标,也用到了多色图标:

  1. 单色图标,将字体图标文件解压缩到 static/fonts 目录中,将 iconfont.css 重命名为 iconfont.scss
代码语言:javascript复制
@font-face {
  font-family: 'iconfont';
  src: url('/static/fonts/iconfont.ttf') format('truetype');
}

.iconfont {
  font-family: 'iconfont' !important;
  font-size: 16px;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.icon-done:before {
  content: 'ea54';
}

.icon-location:before {
  content: 'e6ea';
}

.icon-edit:before {
  content: 'e6e9';
}

.icon-shield:before {
  content: 'e6e8';
}

.icon-checked:before {
  content: 'e6e5';
}

.icon-box:before {
  content: 'e6e6';
}

.icon-truck:before {
  content: 'e6e7';
}

图标成功导入项目后,在 App.vue 中导入自定义图标的样式文件

代码语言:javascript复制
<!-- App.vue -->
<script>
	// 省略这里的代码...
</script>

<style lang="scss">
  // 单色图标
  @import '@/static/fonts/iconfont.scss'
	
  // 以下部分代码省略...
</style>

字体图标导入成功后要到页面测试一下图标是否能正常显示。

  1. 关于多色图标的使用在前面课程中已经介绍过了,关于图标的转换部分就不再演示了,我们直接将转换后代码引入项目中

先将生成的多色图标文件 color-fonts.scss 放到项目的根目录中,然后在 App.vue 中导入该文件

代码语言:javascript复制
<!-- App.vue -->
<script>
	// 省略这里的代码...
</script>

<style lang="scss">
  // 单色图标
  @import '@/static/fonts/iconfont.scss';
  // 多色图标
  @import './color-fonts.scss';

 	// 以下部分代码省略...
</style>

字体图标导入成功后要到页面测试一下图标是否能正常显示。

1.1.5 网站图标

浏览器在加载网页时会在标签页位置展示一个小图标,我们来指定一下这个图标:

代码语言:javascript复制
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
	
  <!-- 这里省略了部分代码... -->
	
  <!-- 这行代码用来指定网站图标 -->
  <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
</head>

<body>
  <div id="app"><!--app-html--></div>
  <script type="module" src="/main.js"></script>
</body>
</html>
1.2 公共封装

封装一系列的公共的方法,如网络请求、轻提示、日期时间处理等。

1.2.1 网络请求

小程序或 uni-app 提供了专门用于网络请求的 API ,但结合实际开发还需要扩展一些与业务相关的逻辑,如基地址、拦截器等功能,通常会对 uni.request 进行封装,luch-request 就是这样一个工具模块,它仿照 axios 的用法对 uni.request 进行二次封装,扩展了基地址、拦截器等业务相关的功能。

  1. 安装 luch-request
代码语言:javascript复制
npm install luch-request
  1. 实例化并配置基地址,项目根目录新建 utils/http.js
代码语言:javascript复制
// utils/http.js

// 导入模块
import Request from 'luch-request'

// 实例化网络请求
const http = new Request({
  // 接口基地址
  baseURL: 'https://t1ps66c7na.hk.aircode.run',
})

// 导出配置好的模网络模块
export { http }
代码语言:javascript复制
<!-- pages/test/index.vue -->
<script setup>
  import { http } from '@/utils/http.js'

  function onButtonClick() {
    // 1. 普通用法
    http.request({
      url: '/echo',
      method: 'GET',
      header: {
        customHeader: '22222222'
      }
    })
  }
</script>
<template>
  <view class="content">
    <button @click="onButtonClick" type="primary">luch-request 测试</button>
  </view>
</template>
  1. 配置请求拦截器

在请求之前执行一些逻辑,例如检测登录状态,添加自定义头信息等。

代码语言:javascript复制
// utils/http.js

// 导入模块
import Request from 'luch-request'

// 实例化网络请求
const http = new Request({
  // 接口基地址
  baseURL: 'https://t1ps66c7na.hk.aircode.run',
})

// 请求拦截器
http.interceptors.request.use(
  function (config) {
    // 定义头信息,并保证接口调用传递的头信息
    // 能够覆盖在拦截器定义的头信息
    config.header = {
      Authorization: '11111111',
      ...config.header,
    }
    
    return config
  },
  function (error) {
    return Promise.reject(error)
  }
)

// 导出配置好的模网络模块
export { http }

以上代码中要注意拦截器中配置的头信息不要将原有的头信息覆盖。

  1. 配置响应拦截器
代码语言:javascript复制
// utils/http.js

// 导入模块
import Request from 'luch-request'

// 实例化网络请求
const http = new Request({
  // 接口基地址
  baseURL: 'https://t1ps66c7na.hk.aircode.run',
})

// 请求拦截器
http.interceptors.request.use(
  function (config) {
    // 定义头信息,并保证接口调用传递的头信息
    // 能够覆盖在拦截器定义的头信息
    config.header = {
      Authorization: '11111111',
      ...config.header,
    }
    
    return config
  },
  function (error) {
    return Promise.reject(error)
  }
)

// 响应拦截器
http.interceptors.response.use(
  function ({ statusCode, data, config }) {
    // 解构出响应主体
    return data
  },
  function (error) {
    return Promise.reject(error)
  }
)

// 导出配置好的模网络模块
export { http }
代码语言:javascript复制
<!-- pages/test/index.vue -->
<script setup>
  import { http } from '@/utils/http.js'

  async function onButtonClick() {
    // 1. 普通用法
    const result = await http.request({
      url: '/echo',
      method: 'GET',
      header: {
        customHeader: '22222222'
      }
    })
    
    console.log(result)
  }
</script>
<template>
  <view class="content">
    <button @click="onButtonClick" type="primary">luch-request 测试</button>
  </view>
</template>
  1. 请求加载状态

在发请求之前展示一个加载提示框,请求结束后隐藏这个提示框,该部分的逻辑分别对应请求拦截器和响应拦截器,在请求拦截器中调用 uni.showLoading 在响应拦截器中调用 uni.hideLoading

在设置加载提示框之前先来了解一下 luch-request 提供的自定义配置参数的功能,即 custom 属性,该属性的用法如下:

代码语言:javascript复制
// utils/http.js

// 导入模块
import Request from 'luch-request'

// 实例化网络请求
const http = new Request({
  // 接口基地址
  baseURL: 'https://t1ps66c7na.hk.aircode.run',
  custom: {
    abc: 123,
    loading: true
  }
})

// 省略以下部分代码...

局部配置了相同的自定义参数时会覆盖全局配置的自定义参数

代码语言:javascript复制
<!-- pages/test/index -->
<script setup>
  import { http } from '@/utils/http.js'

  async function onButtonClick() {
    // 1. 普通用法
    const result = await http.request({
      // 省略部分代码...
      
      // 局部配置自定义参数
      custom: {
        abc: 123,
      },
      // 省略部分代码...
    })

    console.log(result)
  }
</script>

在了解自定义参数的使用后,我们来自定义一个能控制是否需要 loading 提示框的属性,全局默认为 true

代码语言:javascript复制
// utils/http.js

// 导入模块
import Request from 'luch-request'

// 实例化网络请求
const http = new Request({
  // 接口基地址
  baseURL: 'https://t1ps66c7na.hk.aircode.run',
  custom: {
    loading: true
  }
})

// 请求拦截器
http.interceptors.request.use(
  function (config) {
    // 显示加载状态提示
    if (config.custom.loading) {
      uni.showLoading({ title: '正在加载...', mask: true })
    }
    
    // 定义头信息,并保证接口调用传递的头信息
    // 能够覆盖在拦截器定义的头信息
    config.header = {
      Authorization: '11111111',
      ...config.header,
    }
    
    return config
  },
  function (error) {
    return Promise.reject(error)
  }
)

// 响应拦截器
http.interceptors.response.use(
  function ({ statusCode, data, config }) {
    // 隐藏加载状态提示
    uni.hideLoading()
    
    // 解构出响应主体
    return data
  },
  function (error) {
    return Promise.reject(error)
  }
)

// 导出配置好的模网络模块
export { http }

到此关于网络请求的基本用法就封装完毕了,后续会补充登录权限检测的业务逻辑。

1.2.2 轻提示

uni-app 提供了 uni.showToast API 用于轻提示,但其传的参数比较复杂,通过封装来简化参数的传递。

新建 utils/utils.js

代码语言:javascript复制
/**
 * 项目中会用的一系列的工具方法
 */
uni.utils = {
  /**
   * 用户反馈(轻提示)
   * @param {string} title 提示文字内容
   * @param {string} icon 提示图标类型
   */
  toast(title = '数据加载失败!', icon = 'none') {
    uni.showToast({
      title,
      icon,
      mask: true,
    })
  },
}

这里的方法将来是会被全局引用的,因此在入口 main.js 中导入 utils/utils.js

代码语言:javascript复制
import { createSSRApp } from 'vue'

import App from './App'
import '@/utils/utils'

export function createApp() {
  const app = createSSRApp(App)
  return {
    app,
  }
}

在入口文件 main.js 中使用条件编译兼容了 Vue2 和 Vue3,由于本项目确定了要使用 Vue3 且会用到组合式 API,因此可以将 Vu2 部分的代码删除掉。

将来就可以任意位置来使用 utils 的封装了,用法如下所示:

代码语言:javascript复制
<!-- pages/test/index.vue -->
<script setup>
  import { http } from '@/utils/http.js'

  async function onButtonClick() {
    // 1. 普通用法
    const result = await http.request({
      url: '/echo',
      // 省略这里的代码...
    })
		
    // 这是工具方法的用法
    uni.utils.toast('测试轻提示')
  }
</script>

在以后的开发中还会根据需要扩充更多的方法。

二、Pinia 状态管理

Pinia 是 Vue 的专属状态管理库,它允许你跨组件或页面共享状态。

Pinia 起源于一次探索 Vuex 下一个迭代的实验,因此结合了 Vuex 5 核心团队讨论中的许多想法。最后,我们意识到 Pinia 已经实现了我们在 Vuex 5 中想要的大部分功能,所以决定将其作为新的推荐方案来代替 Vuex。

Vuex 3.x 只适配 Vue 2,而 Vuex 4.x 是适配 Vue 3 的,Pinia 可以同时支持 Vue2 和 Vue3。

2.1 安装
代码语言:javascript复制
# 或者使用其它包管理工具,如 yarn pnpm
npm install pinia

创建一个 pinia 实例 (根 store) 并将其传递给应用:

代码语言:javascript复制
// main.js
import { createSSRApp } from 'vue'
// 导入 Pinia
import { createPinia } from 'pinia'

import App from './App'
import '@/utils/utils'

export function createApp() {
  const app = createSSRApp(App)
  // 创建 Pinia 实例
  const pinia = createPinia()
  app.use(pinia)
  
  return {
    app,
  }
}

注意事项:

  • createSSRApp 是配合 SSR 来使用的,其用法与 createApp 相同,在这里 uni-app 为了做跨平台开发所采取的方式,其作用我们就按 createApp 来理解即可。
2.2 Store

在深入研究核心概念之前,我们得知道 Store 是用 defineStore() 定义的,它支持两种语法法格式,分别是选项式 Store 和 组件式 Store,创建一个文件来演示它的使用:

  1. 选项式(Options) Store
代码语言:javascript复制
// stores/counter.js
import { defineStore } from 'pinia'

// 选项式 Store
export const useCounterStore = defineStore('counter', {
  state: () => {
    return {
      count: 0,
    }
  },
  getters: {
    double: (state) => {
      return state.count * 2
    },
  },
  actions: {
    increment() {
      this.count  
    },
    decrement() {
      this.count--
    },
  },
})

pages/test/index 页面中通一个计数器来测试它的用法,下面新增的布局相关代码

代码语言:javascript复制
<!-- pages/test/index.vue -->
<script setup>
  import { http } from '@/utils/http.js'

  // 测试网络请求
  async function onButtonClick() {
		// 省略前面小节代码
  }
</script>

<template>
  <view class="content">
    <!-- 前面小节代码省略了 -->
    <view class="counter">
      <button class="button" type="primary">-</button>
      <input class="input" type="text" />
      <button class="button" type="primary"> </button>
    </view>
  </view>
</template>
<style lang="scss">
  // 前面小节代码省略了
  .counter {
    display: flex;
    margin-top: 30rpx;
  }

  .input {
    flex: 1;
    height: 96rpx;
    text-align: center;
    border: 2rpx solid #eee;
    box-sizing: border-box;
  }

  .button {
    width: 100rpx;
    margin: 0;

    &:first-child {
      border-start-end-radius: 0;
      border-end-end-radius: 0;
    }
    &:last-child {
      border-start-start-radius: 0;
      border-end-start-radius: 0;
    }
  }
</style>

接下来看如何使用 Pinia,在使用时要注意必须要调用定义好的 Store 才会真正创建 Store 实例,对应到下面的代码是必须要调用 useCounterStore 后才会创建 Store 实例,即 counterStore

代码语言:javascript复制
<!-- pages/test/index.vue -->
<script setup>
  import { http } from '@/utils/http.js'
  // 导入定义好的 Store
  import { useCounterStore } from '@/stores/counter.js'
  // 创建 Store 实例
  const counterStore = useCounterStore()

  // 测试网络请求
  async function onButtonClick() {
		// 省略前面小节代码
  }
</script>

<template>
  <view class="content">
    <!-- 前面小节代码省略了 -->
    <view class="counter">
      <button class="button" type="primary">-</button>
      <input class="input" type="text" />
      <button class="button" type="primary"> </button>
    </view>
  </view>
</template>

Store 实例中定义的 stategettersactions 可以直接应用到组件模板当中。

注意事项:

  • 定义 Store 时建议(非必须)使用 use 名称 Store 格式命名,其中名称也会被当做 ID 出现在调试工具中
  • 创建 Store 实例时,实例的名称建议用 名称 Store 格式命名,避免引入多个 Store 时名称重复的问题
  1. 组件式(Setup)Store

组合式 Store 用法与选项式 Store 用法最直接的区别就是 defineStore 的第 2 个参数传入是一个函数,而选项式 Store 传入的是一个对象。

另一个区别是在组合式 API 中允许使用 Vue 的组合式函数,如 refcomputedwatch 等。

在组件式 Store 中:

  • ref() 就是 state 属性
  • computed 就是 getters
  • function() 就是 actions
代码语言:javascript复制
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 1. 选项式 Store

// 这里省略上一小节代码

// 2. 组合式 Store
export const useCounterStore = defineStore('counter', () => {
  // 定义 state
  const count = ref(0)
  
  // 定义 getters
  const double = computed(() => count.value * 2)
  
  // 定义 actions
  function increment() {
    count.value  
  }
  function decrement() {
    count.value--
  }

  // 千万不要忘记这里要 return
  return { count, double, increment, decrement }
})

对比发现组合式 Store 的用法与 Vue 组件的 setup 用法是一致的,咱们项目中会采用这种用法来开发。

2.3 State

State 实际上就是用来共享访问的数据,这些数据会涉及到访问、变更等操作,我们分别来学习 State 的相关操作。

  1. 访问,State 的数据是使用 reactive 创建的,直接通过 Store 实例属性的方式即可访问,这种访问方式也包括了 getters 的访问。
代码语言:javascript复制
<!-- pages/test/index.vue -->
<script setup>
  // 导入定义好的 Store
  import { useCounterStore } from '@/stores/counter.js'
  // 创建 Store 实例
  const counterStore = useCounterStore()
  // 像普通过 reactive 包装数据一样来访问
  console.log(counterStore.count)
  // getters 可以可采用相同的方式来访问
  console.log(counterStore.double)
</script>

但是这里一定要注意是 reactive 的数据不允许解构,解构后的数据将会失去响应式,为了解决这个问题可以使用 Pinia 提供的工具函数 storeToRefs

代码语言:javascript复制
<!-- pages/test/index.vue -->
<script setup>
  // 导入工具方法解构 State 数据
  import { storeToRefs } from 'pinia'
  // 导入定义好的 Store
  import { useCounterStore } from '@/stores/counter.js'
  // 创建 Store 实例
  const counterStore = useCounterStore()
	
  // 直接解构是错误的用法
  // const { count, double } = counterStore
  // 正确的解构方法
  const { count, double } = storeToRefs(counterStore)
</script>
<template>
  <view class="content">
		<!-- 省略前面小节的代码... -->
    <view class="counter">
      <button @click="counterStore.decrement" class="button" type="primary">
        -
      </button>
      <input class="input" :value="counterStore.count" type="text" />
      <button @click="counterStore.increment" class="button" type="primary">
         
      </button>
    </view>
    <!-- 在这里访问解构后的数据 -->
    <view class="state">
      <text class="text">count: {{ count }}</text>
      <text class="text">double: {{ double }}</text>
    </view>
  </view>
</template>
  1. 变更

变更 State 的数据有两种方式,一种是直接赋值,另一种是调用 $patch 方法。

代码语言:javascript复制
<!-- pages/test/index.vue -->
<script setup>
  import { storeToRefs } from 'pinia'
  
  // 导入定义好的 Store
  import { useCounterStore } from '@/stores/counter.js'
  // 创建 Store 实例
  const counterStore = useCounterStore()

  let _count = 0
  // 更新 state
  function increment() {
    // 直接等号赋值
    // counterStore.count  
    // 调用 $patch 方法
    counterStore.$patch({
      count:   _count,
    })
  }
  // 更新 state
  function decrement() {
    // 直接等号赋值
    // counterStore.count--
    // 调用 $patch 方法
    counterStore.$patch({
      count: --_count,
    })
  }
</script>
<template>
  <view class="content">
    <!-- 省略前面小节部分代码... -->
    <view class="counter">
      <button @click="decrement" class="button" type="primary">-</button>
      <input class="input" :value="counterStore.count" type="text" />
      <button @click="increment" class="button" type="primary"> </button>
    </view>
    <view class="state">
      <text class="text">count: {{ count }}</text>
      <text class="text">double: {{ double }}</text>
    </view>
  </view>
</template>

这两种方式都可以用来对 State 数据进行修改,在一次性需要更新多个数据时推荐使用 $patch 方法,单个数据更新时使用等号直接赋值。

2.4 持久化

Pinia 的数据是以全局的方式存储在内存中的,这会导致页面被刷新后数据丢失或重置,但实际开发中有的数据需要长时间的存储,即所谓的持久化,通常都是存入本地存储当中来实现的,在 Pinia 中通过插件来扩展持久化的功能。

  1. 安装
代码语言:javascript复制
# 也可以使用其它包管理工具,如 yarn pnpm
npm i pinia-plugin-persistedstate
  1. 将插件添加 Pinia 实例上
代码语言:javascript复制
import { createSSRApp } from 'vue'
// 导入 Pinia
import { createPinia } from 'pinia'
// Pinia 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

import App from './App'
import '@/utils/utils'

export function createApp() {
  const app = createSSRApp(App)
  // 创建 Pinia 实例
  const pinia = createPinia()
  // 应用 Pinia 插件
  pinia.use(piniaPluginPersistedstate)

  app.use(pinia)

  return {
    app,
  }
}
  1. 将数据持久化存储,为 defineStore 传入第3个参数,第3个参数是对象类型
代码语言:javascript复制
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 2. 组合式 Store
export const useCounterStore = defineStore(
  'counter',
  () => {
    // 定义 state
    const count = ref(0)
    // 定义 getters
    const double = computed(() => count.value * 2)
    // 定义 actions
    function increment() {
      count.value  
    }
    function decrement() {
      count.value--
    }

    // 千万不要忘记这里要 return
    return { count, double, increment, decrement }
  },
  { persist: true }
)

count 数据发生改变后就会将数据存入本地存储当中了,但是这种方式有个弊端就是会将所有 State 数据持久化存储,这样会造成不必要的性能损耗,要解决这个问题也非常方便,通过 paths 来指定需要持久化存储的数据:

代码语言:javascript复制
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 2. 组合式 Store
export const useCounterStore = defineStore(
  'counter',
  () => {
    // 定义 state
    const count = ref(0)
    // 定义 getters
    const double = computed(() => count.value * 2)
    // 定义 actions
    function increment() {
      count.value  
    }
    function decrement() {
      count.value--
    }

    // 千万不要忘记这里要 return
    return { count, double, increment, decrement }
  },
  {
    persist: {
      paths: ['count'],
    },
  }
)
  1. 配置

以上的用法在一般的 Vue 项目中可以满足基本的开发需要了,但是在 uni-app 中时却需要做一些额外的配置,原因在于 uni-app 中本地存储使用的是 uni.setStorageSync 而插件中使用的是 localStorage.setItem,为此需要我们自定义配置本地址存储的方法。

使用 createPersistedState 进行全局性配置

代码语言:javascript复制
// main.js
import { createSSRApp } from 'vue'
// 导入 Pinia
import { createPinia } from 'pinia'
// Pinia 持久化插件
import { createPersistedState } from 'pinia-plugin-persistedstate'
// import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

import App from './App'
import '@/utils/utils'

export function createApp() {
  const app = createSSRApp(App)
  // 创建 Pinia 实例
  const pinia = createPinia()
  
  // 应用 Pinia 插件
  // pinia.use(piniaPluginPersistedstate)
  pinia.use(
    // 自定义 Pinia 插件
    createPersistedState({
      // 自定义本地存储的逻辑
      storage: {
        setItem(key, value) {
          uni.setStorageSync(key, value)
        },
        getItem(key) {
          return uni.getStorageSync(key)
        },
      },
    })
  )

  app.use(pinia)
  
  return {
    app,
  }
}

createPersistedState 传的参数中 storage 是用来自定义持久化存储方法的,其中 setItemgetItem 是内置固定的名称,在进行本地存储时插件内部会自动调用这两个方法,进而调用 uni.setStorageSync 将数据存入本地。

另外存入本地数据的名称默认为 Store 的名称,这个名称也允许自定义,使用 key 来指定:

代码语言:javascript复制
// main.js
import { createSSRApp } from 'vue'
// 导入 Pinia
import { createPinia } from 'pinia'
// Pinia 持久化插件
import { createPersistedState } from 'pinia-plugin-persistedstate'
// import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

import App from './App'
import '@/utils/utils'

export function createApp() {
  const app = createSSRApp(App)
  // 创建 Pinia 实例
  const pinia = createPinia()
  // 应用 Pinia 插件
  // pinia.use(piniaPluginPersistedstate)
  
  pinia.use(
    // 自定义 Pinia 插件
    createPersistedState({
      // 自定义本地存数据的名称
      key: (id) => `__persisted__${id}`,
      // 自定义本地存储的逻辑
      storage: {
        setItem(key, value) {
          uni.setStorageSync(key, value)
        },
        getItem(key) {
          return uni.getStorageSync(key)
        },
      },
    })
  )

  app.use(pinia)
  
  return {
    app,
  }
}
三、用户登录

优医问诊提供了 3 种登录方式,分别是用户名和密码、短信验证码、社交账号登录(暂只支持QQ登录),先来实现前两种方式的登录,关于第3方登录我们最后再来实现。

3.1 布局及交互

新建 pages/login/index.vue 页面,创建页面时容易出错的地方是新建的页面路径要添加到 pages.json 文件中,同时将页面志航栏标题设置为用户登录。

用户名&密码方式登录和短信验证码方式登录有一个 Tab 切换显示的的交互,我们大致的实现思路如下 :

3.1.1 布局模板
  1. 定义 Tab 标签页切换的基础结构
代码语言:javascript复制
<!-- pages/login/index.vue -->
<script setup></script>
<template>
  <view class="user-login">
    <view class="login-type">
      <view class="title">密码登录</view>
      <view class="type">
        <text>验证码登录</text>
        <uni-icons color="#3c3e42" type="forward" />
      </view>
    </view>
  </view>
  <!-- 社交账号登录 -->
  <view class="social-login">
    <view class="legend">
      <text class="text">其它方式登录</text>
    </view>
    <view class="social-account">
      <view class="icon">
        <uni-icons color="#00b0fb" size="30" type="qq" />
      </view>
      <view class="icon">
        <uni-icons color="#fb6622" size="30" type="weibo" />
      </view>
      <view class="icon">
        <uni-icons color="#07C160" size="30" type="weixin" />
      </view>
    </view>
  </view>
</template>

<style lang="scss">
	@import './index.scss'
</style>
代码语言:javascript复制
// pages/login/index.scss
.user-login {
  padding: 60rpx;
}

.login-type {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  line-height: 1;
  margin: 40rpx 0 80rpx;

  .title {
    font-size: 48rpx;
    color: #121826;
  }

  .type {
    color: #3c3e42;
    font-size: 30rpx;
    display: flex;
    align-items: center;
  }
}

.social-login {
  margin-top: 100rpx;
  padding: 0 60rpx;

  .legend {
    height: 40rpx;
    position: relative;
    border-top: 1rpx solid #ebebeb;
  }

  .text {
    position: absolute;
    top: -50%;
    left: 50%;

    font-size: 28rpx;
    color: #999;
    padding: 0 10rpx;
    background-color: #fff;
    transform: translate(-50%);
  }

  .social-account {
    display: flex;
    justify-content: space-evenly;
    margin-top: 40rpx;

    .icon {
      display: flex;
      justify-content: center;
      align-items: center;
      width: 80rpx;
      height: 80rpx;
      border-radius: 100rpx;
      background-color: #f6f6f6;
    }
  }
}

注意事项:在以上的页面布局模板中用到了扩展组件 uni ui ,需要安装到项目录中,并重新启动项目。

3.1.2 标签切换

经分析后发现,要在页面中展示不同的文字内容,并且在用户点击后进行切换,实现步骤如下:

  1. 定义一个对象数组,该数组中包含了要展示在页面中的内容
代码语言:javascript复制
<!-- pages/login/index.vue -->
<script setup>
  import { ref, computed } from 'vue'
  // 标签页要展示的内容
  const tabMetas = [
    { title: '密码登录', subTitle: '验证码登录' },
    { title: '验证码登录', subTitle: '密码登录' },
  ]
  
  // 标签页的索引值
  const tabIndex = ref(0)
  //  根据索引值决定当前标签展示的内容
  const tabMeta = computed(() => {
    return tabMetas[tabIndex.value]
  })
</script>

<template>
  <view class="user-login">
    <view class="login-type">
      <view class="title">{{ tabMeta.title }}</view>
      <view class="type">
        <text>{{ tabMeta.subTitle }}</text>
        <uni-icons color="#3c3e42" type="forward" />
      </view>
    </view>
  </view>
  <!-- 社交账号登录 -->
  <view class="social-login">
    ...
  </view>
</template>
  1. 监听点击事件,切根据索引值来切换显示不同的内容
代码语言:javascript复制
<!-- pages/login/index.vue -->
<script setup>
  import { ref, computed } from 'vue'

  // 标签页要展示的内容
  const tabMetas = [
    { title: '密码登录', subTitle: '验证码登录' },
    { title: '验证码登录', subTitle: '密码登录' },
  ]
  // 标签页的索引值
  const tabIndex = ref(0)
  //  根据索引值决定当前标签展示的内容
  const tabMeta = computed(() => {
    return tabMetas[tabIndex.value]
  })

  // 切换标签页的索引值
  function onSubTitleClick() {
    // 0 和 1 互换的简单算法
    tabIndex.value = Math.abs(tabIndex.value - 1)
  }
</script>
<template>
...
</template>
  1. 封装用户名&密码组件和短信验证码组件,在当前目录下创建 components/mobile.vuecomponents/password.vue组件,组件的布局模板为:

password.vue

代码语言:javascript复制
<!-- pages/login/components/passoword.vue -->
<script setup></script>
<template>
  <uni-forms class="login-form" ref="form">
    <uni-forms-item name="mobile">
      <uni-easyinput
        :input-border="false"
        :clearable="false"
        placeholder="请输入手机号"
        placeholder-style="color: #C3C3C5"
      />
    </uni-forms-item>
    <uni-forms-item name="password">
      <uni-easyinput
        type="password"
        placeholder="请输入密码"
        :input-border="false"
        placeholder-style="color: #C3C3C5"
      />
    </uni-forms-item>
    <view class="agreement">
      <radio :checked="false" color="#16C2A3" />
      我已同意
      <text class="link">用户协议</text>
      及
      <text class="link">隐私协议</text>
    </view>

    <button class="uni-button">登 录</button>
    <navigator hover-class="none" class="uni-navigator" url=" ">
      忘记密码?
    </navigator>
  </uni-forms>
</template>

<script>
  export default {
    options: {
      styleIsolation: 'shared',
    },
  }
</script>

<style lang="scss">
  @import './styles.scss';
</style>

mobile.vue

代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup></script>

<template>
  <uni-forms class="login-form" ref="form">
    <uni-forms-item name="name">
      <uni-easyinput
        :input-border="false"
        :clearable="false"
        placeholder="请输入手机号"
        placeholder-style="color: #C3C3C5"
      />
    </uni-forms-item>
    <uni-forms-item name="name">
      <uni-easyinput
        :input-border="false"
        :clearable="false"
        placeholder="请输入验证码"
        placeholder-style="color: #C3C3C5"
      />
      <text class="text-button">获取验证码</text>
    </uni-forms-item>

    <view class="agreement">
      <radio :checked="false" color="#16C2A3" />
      我已同意
      <text class="link">用户协议</text>
      及
      <text class="link">隐私协议</text>
    </view>

    <button class="uni-button">登 录</button>
  </uni-forms>
</template>

<script>
  export default {
    options: {
      styleIsolation: 'shared',
    },
  }
</script>

<style lang="scss">
  @import './styles.scss';
</style>

公共的样式 styles.scss

代码语言:javascript复制
// pages/login/components/styles.scss
.uni-forms-item {
  height: 80rpx;
  margin-bottom: 30rpx !important;
  border-bottom: 1rpx solid #ededed;
  box-sizing: border-box;
  position: relative;
}

.agreement {
  font-size: 26rpx;
  color: #3c3e42;
  display: flex;
  align-items: center;
  margin-top: 50rpx;
  margin-left: -10rpx;

  .link {
    color: #16c2a3;
  }

  :deep(.uni-radio-wrapper) {
    transform: scale(0.6);
  }

  /* #ifdef MP */
  radio {
    transform: scale(0.6);
  }
  /* #endif */

  :deep(.uni-radio-input) {
    margin-right: 0 !important;
  }
}

:deep(.uniui-eye-filled),
:deep(.uniui-eye-slash-filled) {
  color: #6f6f6f !important;
}

:deep(.uni-forms-item__content) {
  display: flex;
  align-items: center;
}

:deep(.uni-forms-item__error) {
  width: 100%;
  padding-top: 10rpx;
  padding-left: 10rpx;
  border-top: 2rpx solid #eb5757;
  color: #eb5757;
  font-size: 24rpx;
  transition: none;
}

.text-button {
  display: flex;
  justify-content: flex-end;
  width: 240rpx;
  padding-left: 10rpx;
  font-size: 28rpx;
  color: #16c2a3;
  border-left: 2rpx solid #eee;
}

.uni-button {
  margin-top: 50rpx;

  &[disabled] {
    background-color: #fafafa;
    color: #d9dbde;
  }
}

.uni-navigator {
  margin-top: 30rpx;
  text-align: center;
  color: #848484;
  font-size: 28rpx;
}

最后将组件导入到页面中,根据索引值来渲染相应的组件:

代码语言:javascript复制
<!-- pages/login/index.vue -->
<script setup>
  import { ref, computed } from 'vue'
  // 导入组件
  import customPassword from './components/password.vue'
  import customMobile from './components/mobile.vue'

  // 标签页要展示的内容
  const tabMetas = [
    { title: '密码登录', subTitle: '验证码登录' },
    { title: '验证码登录', subTitle: '密码登录' },
  ]
  // 标签页的索引值
  const tabIndex = ref(1)
  //  根据索引值决定当前标签展示的内容
  const tabMeta = computed(() => {
    return tabMetas[tabIndex.value]
  })

  // 切换标签页的索引值
  function onSubTitleClick() {
    // 0 和 1 互换的简单算法
    tabIndex.value = Math.abs(tabIndex.value - 1)
  }
</script>

<template>
  <view class="user-login">
    <view class="login-type">
      <view class="title">{{ tabMeta.title }}</view>
      <view class="type">
        <text @click="onSubTitleClick">{{ tabMeta.subTitle }}</text>
        <uni-icons color="#3c3e42" type="forward" />
      </view>
    </view>
    <!-- 用户名&密码方式 -->
    <custom-password v-if="tabIndex === 0" />
    <!-- 短信验证码方式 -->
    <custom-mobile v-if="tabIndex === 1" />
  </view>
  <!-- 社交账号登录 -->
  <view class="social-login">
    ...
  </view>
</template>
3.2 短信验证码登录

短信息验证码登录的大致流程如下:

  1. 用户填写正确的手机号码
  2. 向用户的手机号发送短信
  3. 用户填写接收到的短信验证码
  4. 同时提交验证码和手机号

============================================

  • 提供了100个测试账号
  • 手机号:13230000001 - 13230000100
  • 密码:abc12345

============================================

3.2.1 倒计时组件

在获取短信码的过程中常常会配合倒计时的交互,提醒用户在 60秒可重新获取验证码,该交互可以使扩展组件 uni-countdown,但是这个组件存在一些缺陷,我们将其改造后再使用:

  • uni_modules/uni-countdown/components/uni-countdown 内的全部内容拷贝到 /components/custom-countdown 目录中
  • uni-countdown.vue 重命名为 costom-countdown.vue (目的是要符合 easycom 规范)
  1. 扩展(修改)组件
代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
  // 是否显示倒时计组件
  const showCountdown = ref(false)
  // 按钮文件
  const buttonText = ref('获取验证码')

  // 发送短信验证码
  function onTextButtonClick() {
    // 将来这里调用接口,发送短信...
    // 显示倒计时组件
    showCountdown.value = true
  }
</script>

<template>
  <uni-forms class="login-form" ref="form">
    <uni-forms-item name="name">
      ...
    </uni-forms-item>
    <uni-forms-item name="name">
      ...
      <view v-if="showCountdown" class="text-button">
        <custom-countdown
          :second="60"
          :show-day="false"
          color="#16C2A3"
        />
      </view>
      <text v-else @click="onTextButtonClick" class="text-button">
        {{ buttonText }}
      </text>
    </uni-forms-item>

    <view class="agreement">
     ...
    </view>
    <button class="uni-button">登 录</button>
  </uni-forms>
</template>

为组件添加3个属性来控制是否显示 “时、分”,showHourshowMiniute

代码语言:javascript复制
<!-- /components/custom-countdown/custom-countdown.vue -->
<template>
  <view class="uni-countdown">
    <text v-if="showDay" :style="[timeStyle]" class="uni-countdown__number">
      {{ d }}
    </text>
    <text v-if="showDay" :style="[splitorStyle]" class="uni-countdown__splitor">
      {{ dayText }}
    </text>
    <text v-if="showHour" :style="[timeStyle]" class="uni-countdown__number">
      {{ h }}
    </text>
    <text
      v-if="showHour"
      :style="[splitorStyle]"
      class="uni-countdown__splitor"
    >
      {{ showColon ? ':' : hourText }}
    </text>
    <text v-if="showMiniute" :style="[timeStyle]" class="uni-countdown__number">
      {{ i }}
    </text>
    <text
      v-if="showMiniute"
      :style="[splitorStyle]"
      class="uni-countdown__splitor"
    >
      {{ showColon ? ':' : minuteText }}
    </text>
    <text :style="[timeStyle]" class="uni-countdown__number">{{ s }}</text>
    <text
      v-if="!showColon"
      :style="[splitorStyle]"
      class="uni-countdown__splitor"
    >
      {{ secondText }}
    </text>
  </view>
</template>
<script>
  import { initVueI18n } from '@dcloudio/uni-i18n'
  import messages from './i18n/index.js'
  const { t } = initVueI18n(messages)
  export default {
    name: 'UniCountdown',
    emits: ['timeup'],
    props: {
      showDay: {
        type: Boolean,
        default: true,
      },
      // *********
      showHour: {
        type: Boolean,
        default: true,
      },
      showMiniute: {
        type: Boolean,
        default: true,
      },
      // *********
    },
  }
</script>
<style lang="scss" scoped>
...
</style>

监听custom-countdown 组件的事件 @timeup,在倒时结束时允许用户重新获取验证码:

代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
  // 是否显示倒时计组件
  const showCountdown = ref(false)
  // 按钮文件
  const buttonText = ref('获取验证码')

    // 监听倒计时组件是否结束
  function onCountdownTimeup() {
    // 变更提示文字
    buttonText.value = '重新获取验证码'
    // 隐藏倒计时组件
    showCountdown.value = false
  }
  
  // 发送短信验证码
  function onTextButtonClick() {
    // 将来这里调用接口,发送短信...
    // 显示倒计时组件
    showCountdown.value = true
  }
</script>

<template>
  <uni-forms class="login-form" ref="form">
    <uni-forms-item name="name">
      ...
    </uni-forms-item>
    <uni-forms-item name="name">
      ...
      <view v-if="showCountdown" class="text-button">
        <custom-countdown
          :second="59"
          :show-day="false"
          :show-hour="false"
          :show-miniute="false"
          @timeup="onCountdownTimeup"
          color="#16C2A3"
        />
      </view>
      <text v-else @click="onTextButtonClick" class="text-button">
        {{ buttonText }}
      </text>
    </uni-forms-item>

    <view class="agreement">
     ...
    </view>
    <button class="uni-button">登 录</button>
  </uni-forms>
</template>

注意事项:以上的倒时计组件显示时间时,如果设置为 60秒时,会被处理成 ‘01:00’,因此看到的秒数是 ‘00’,而不是 ‘60’,这个小瑕疵我们可以将时间置成 59秒,偷懒的方式解决这个问题。

3.2.2 表单数据验证

要验证表单的数据是否合法,需要3个步骤:

  1. 获取表单的数据
代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
	
  // 省略前面小节代码...

  // 表单数据
  const formData = ref({
    mobile: '',
    code: '',
  })

	// 省略前面小节代码
</script>

<template>
  <uni-forms class="login-form" :model="formData" ref="form">
    <uni-forms-item name="mobile">
      <uni-easyinput
        v-model="formData.mobile"
        :input-border="false"
        :clearable="false"
        placeholder="请输入手机号"
        placeholder-style="color: #C3C3C5"
      />
    </uni-forms-item>
    <uni-forms-item name="code">
      <uni-easyinput
        v-model="formData.code"
        :input-border="false"
        :clearable="false"
        placeholder="请输入验证码"
        placeholder-style="color: #C3C3C5"
      />
      <view v-if="showCountdown" class="text-button">
        <custom-countdown
          :second="60"
          :show-day="false"
          :show-hour="false"
          :show-miniute="false"
          @timeup="onCountdownTimeup"
          color="#16C2A3"
        />
        秒后重新获取
      </view>
      <text v-else @click="onTextButtonClick" class="text-button">
        {{ buttonText }}
      </text>
    </uni-forms-item>
    <view class="agreement">
     ...
    </view>
    <button class="uni-button">登 录</button>
  </uni-forms>
</template>

注意事项,以上代码中关键的部分为:

  • uni-forms 组件添加 :model 属性
  • uni-forms-item 组件添加 name 属性
  • uni-easyinput 组件添加 v-model 属性
  1. 定义验证规则
代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
	
  // 省略前面小节代码...

  // 表单数据
  const formData = ref({
    mobile: '',
    code: '',
  })
  
  // 验证表单数据的规则
  const formRules = {
    mobile: {
      rules: [
        { required: true, errorMessage: '请填写手机号码' },
        { pattern: '^1\d{10}$', errorMessage: '手机号码格式不正确' },
      ],
    },
    code: {
      rules: [
        { required: true, errorMessage: '请输入验证码' },
        { pattern: '^\d{6}$', errorMessage: '验证码格式不正确' },
      ],
    },
  }

	// 省略前面小节代码
</script>

<template>
  <uni-forms class="login-form" :model="formData" :rules="formRules" ref="formRef">
   	...
  </uni-forms>
</template>

注意事项,以上代码中关键部分为:

  • uni-forms 组件添加了 :rules 属性
  • 定义验证规则时,验证规要与 uni-forms-itemname 属性相对应
  1. 调用验证方法
代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
	
  // 省略前面小节代码...

  // 表单数据
  const formData = ref({
    mobile: '',
    code: '',
  })
  
  // 验证表单数据的规则
  const formRules = {
    mobile: {
      rules: [
        { required: true, errorMessage: '请填写手机号码' },
        { pattern: '^1\d{10}$', errorMessage: '手机号码格式不正确' },
      ],
    },
    code: {
      rules: [
        { required: true, errorMessage: '请输入验证码' },
        { pattern: '^\d{6}$', errorMessage: '验证码格式不正确' },
      ],
    },
  }
  
  // 提交表单数据
  async function onFormSubmit() {
    // 调用 uniForms 组件验证数据的方法
    try {
      // 验证通过后会返回表单的数据
      const formData = await formRef.value.validate()
    } catch (error) {
      console.log(error)
    }
  }

	// 省略前面小节代码
</script>

<template>
  <uni-forms class="login-form" :model="formData" :rules="formRules" ref="formRef">
   	...
    <button @click="onFormSubmit" class="uni-button">登 录</button>
  </uni-forms>
</template>
3.3.3 调用接口

将表单的数据发送给服务端接口,分成 2 个步骤来实现:

  1. 封装接口调用的方法,接口文档的地址查看这里,同时修改接口的基地址和自定义请求头 Authorization
代码语言:javascript复制
// utils/http.js

// 导入模块
import Request from 'luch-request'

// 实例化网络请求
const http = new Request({
  // 接口基地址
  baseURL: 'https://consult-api.itheima.net/',
  custom: {
    loading: true,
  },
})

// 请求拦截器
http.interceptors.request.use(
  function (config) {
    // 显示加载状态提示
    if (config.custom.loading) {
      uni.showLoading({ title: '正在加载...', mask: true })
    }

    config.header = {
      // Authorization: '22222222',
      ...config.header,
    }
    return config
  },
  function (error) {
    return Promise.reject(error)
  }
)

// 省略前面小节代码...

将接口调用的方法进行统一的管理,放到 services 目录中,然后分模块来对接口的调用进行封装。

代码语言:javascript复制
// services/user.js

// 导入封装好的网络请求模块
import { http } from '@/utils/http'

/**
 * 发送验证码
 */
export const verifyCodeApi = (data) => {
  // get 方法的参数需要通过 params 来传递
  return http.get('/code', { params: data })
}

/**
 * 用户登录接口(短信验证码方式)
 */
export const loginByMobileApi = (data) => {
  return http.post('/login', data)
}

注意事项,上述代码中将 Api 做为方法名的后缀,如 loginByMobileApi,目的是方便代码的阅读,一目了然的知道是对接口调用进行的封装。

HBuilder X 使用小技巧:在代码中直接写封装好的 API 方法,根据提示可以快速引用相应的文件模块。

先来调用接口获取短信验证码

代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
  import { verifyCodeApi } from '@/services/user'

  // 省略前面小节的代码

  // 提交表单数据
  async function onFormSubmit() {
    // 调用 uniForms 组件验证数据的方法
    try {
      // 验证通过后会返回表单的数据
      const formData = await formRef.value.validate()
    } catch (error) {
      console.log(error)
    }
  }

  // 省略前面小节的代码...

  // 发送短信验证码
  async function onTextButtonClick() {
    // 将来这里调用接口,发送短信...
    const { code, message } = await verifyCodeApi({
      mobile: formData.value.mobile,
      type: 'login',
    })
    // 检测接口是否调用成功
    if (code !== 10000) return uni.utils.toast(message)
    
    uni.utils.toast('验证码已发送,请查收!')
    
    // 显示倒计时组件
    showCountdown.value = true
  }
</script>

接收到短信验证码之后再来将表单的全部数据 mobilecode 提交给接口

代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
  import { loginByMobileApi, verifyCodeApi } from '@/services/user'

  // 省略前面小节的代码

  // 提交表单数据
  async function onFormSubmit() {
    // 调用 uniForms 组件验证数据的方法
    try {
      // 验证通过后会返回表单的数据
      const formData = await formRef.value.validate()
      // 提交表单数据
      const { code, data, message } = await loginByMobileApi(formData)
      // 检测接口是否调用成功
      if (code !== 10000) return uni.utils.toast(message)
    } catch (error) {
      console.log(error)
    }
  }

  // 省略前面小节的代码...
</script>
  1. 记录用户登录状态,通过 Pinia 将登录状态记录下来

新建用于管理用户数据的 Store,通过 token 来记录用户的登录状态:

代码语言:javascript复制
// stores/user.js
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useUserStore = defineStore(
  'user',
  () => {
    // 记录用户登录状态
    const token = ref('')

    return { token }
  },
  {
    persist: {
      // 指定需要持久化的数据
      paths: ['token'],
    },
  }
)

接下来在登录成功后来更新 Pinia 中的 token

代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
  import { loginByMobileApi, verifyCodeApi } from '@/services/user'
  import { useUserStore } from '@/stores/user'
  
  // 用户相关的数据
  const userStore = useUserStore()

  // 省略前面小节的代码

  // 提交表单数据
  async function onFormSubmit() {
    // 调用 uniForms 组件验证数据的方法
    try {
      // 验证通过后会返回表单的数据
      const formData = await formRef.value.validate()
      // 提交表单数据
      const { code, data, message } = await loginByMobileApi(formData)
      // 检测接口是否调用成功
      if (code !== 10000) return uni.utils.toast(message)
      
      // 持久化存储 token
      userStore.token = data.token
      // 临时跳转到首页面
      uni.switchTab({
        url: '/pages/index/index',
      })
    } catch (error) {
      console.log(error)
    }
  }

  // 省略前面小节的代码...
</script>
四、作业
4.1 部分表单验证

uniForms 提供的 validate 方法来验证整个表单的数据,还提供了 validateField 方法来验证部分表单数据,其语法如下:

代码语言:javascript复制
 // uniForms 表单组件
  const formRef = ref()
  // 传入一个数组,数组中每个单元即要验证的数据名称,返回值为 Promise
  formRef.value.validateField(['mobile', '数据2', '数据3'])

知道 validateField 的用法后,将来整合到项目中

代码语言:javascript复制
<script setup>
  import {ref} from 'vue'
  
  // uniForms 表单组件
  const formRef = ref()
  
  // 省略前面小节的代码...
  
  // 发送短信验证码
  async function onTextButtonClick() {
    try {
      // 验证表单数据(手机号)
      await formRef.value.validateField(['mobile'])

      // 将来这里调用接口,发送短信...
      const { code, message } = await verifyCodeApi({
        mobile: formData.value.mobile,
        type: 'login',
      })

      // 检测接口是否调用成功
      if (code !== 10000) return uni.utils.toast(message)

      uni.utils.toast('验证码已发送,请查收!')

      // 显示倒计时组件
      showCountdown.value = true
    } catch (error) {
      console.log(error)
    }
  }
</script>
4.2 是否同意协议

监听 checkbox 组件的单击事件,变更组件的 checked 属性,true 为选中,false 为不选中

代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
  import { loginByMobileApi, verifyCodeApi } from '@/services/user'
  import { useUserStore } from '@/stores/user'

  // 用户相关的数据
  const userStore = useUserStore()

  // 是否同意协议
  const isAgree = ref(false)
 
  // 省略前面小节代码...

  // 是否同意协议
  function onAgreeClick() {
    isAgree.value = !isAgree.value
  }

  // 省略前面小节代码...
</script>

<template>
  <uni-forms
    class="login-form"
    :model="formData"
    :rules="formRules"
    ref="formRef"
  >
    ...
    <view class="agreement">
      <radio @click="onAgreeClick" :checked="isAgree" color="#16C2A3" />
      我已同意
      <text class="link">用户协议</text>
      及
      <text class="link">隐私协议</text>
    </view>
    <button @click="onFormSubmit" class="uni-button">登 录</button>
  </uni-forms>
</template>

在点击表单提交按钮后判断 isAgree 的值是否为 true

代码语言:javascript复制
<!-- pages/login/components/mobile.vue -->
<script setup>
  import { ref } from 'vue'
  import { loginByMobileApi, verifyCodeApi } from '@/services/user'
  import { useUserStore } from '@/stores/user'

  // 用户相关的数据
  const userStore = useUserStore()

  // 是否同意协议
  const isAgree = ref(false)
 
  // 省略前面小节代码...
  
  // 提交表单数据
  async function onFormSubmit() {
    // 判断是否勾选协议
    if (!isAgree.value) return uni.utils.toast('请先同意协议!')
		
    // 省略前面小节代码...
  }

  // 是否同意协议
  function onAgreeClick() {
    isAgree.value = !isAgree.value
  }

  // 省略前面小节代码...
</script>

<template>
  <uni-forms
    class="login-form"
    :model="formData"
    :rules="formRules"
    ref="formRef"
  >
    ...
    <view class="agreement">
      <radio @click="onAgreeClick" :checked="isAgree" color="#16C2A3" />
      我已同意
      <text class="link">用户协议</text>
      及
      <text class="link">隐私协议</text>
    </view>
    <button @click="onFormSubmit" class="uni-button">登 录</button>
  </uni-forms>
</template>
4.3 用户名和密码登录

参考代码:

代码语言:javascript复制
// services/user.js

// 导入封装好的网络请求模块
import { http } from '@/utils/http'

/**
 * 发送验证码
 */
export const verifyCodeApi = (data) => {
  return http.get('/code', { params: data })
}

/**
 * 用户登录接口(短信验证码方式)
 */
export const loginByMobileApi = (data) => {
  return http.post('/login', data)
}

/**
 * 用户登录接口(密码方式)
 */
export const loginByPassword = (data) => {
  return http.post('/login/password', data)
}
代码语言:javascript复制
<!-- pages/login/components/password.vue -->
<script setup>
  import { ref } from 'vue'
  import { loginByPassword } from '@/services/user'
  import { useUserStore } from '@/stores/user'

  // 用户相关的数据
  const userStore = useUserStore()

  // 是否同意协议
  const isAgree = ref(false)
  // 获取表单组件
  const formRef = ref()

  // 表单数据
  const formData = ref({
    mobile: '',
    password: '',
  })

  // 验证表单数据的规则
  const formRules = {
    mobile: {
      rules: [
        { required: true, errorMessage: '请填写手机号码' },
        { pattern: '^1\d{10}$', errorMessage: '手机号码格式不正确' },
      ],
    },
    password: {
      rules: [
        { required: true, errorMessage: '请输入验证码' },
        { pattern: '^[a-zA-Z0-9]{8}$', errorMessage: '密码格式不正确' },
      ],
    },
  }

  // 是否同意协议
  function onAgreeClick() {
    isAgree.value = !isAgree.value
  }

  // 提交表单数据
  async function onFormSubmit() {
    // 判断是否勾选协议
    if (!isAgree.value) return uni.utils.toast('请先同意协议!')

    // 调用 uniForms 组件验证数据的方法
    try {
      // 验证通过后会返回表单的数据
      const formData = await formRef.value.validate()
      // 提交表单数据
      const { code, data, message } = await loginByPassword(formData)
      // 检测接口是否调用成功
      if (code !== 10000) return uni.utils.toast(message)

      // 持久化存储 token
      userStore.token = data.token
      // 临时跳转到首页面
      uni.switchTab({
        url: '/pages/index/index',
      })
    } catch (error) {
      console.log(error)
    }
  }
</script>

<template>
  <uni-forms
    class="login-form"
    :model="formData"
    :rules="formRules"
    ref="formRef"
  >
    <uni-forms-item name="mobile">
      <uni-easyinput
        v-model="formData.mobile"
        :input-border="false"
        :clearable="false"
        placeholder="请输入手机号"
        placeholder-style="color: #C3C3C5"
      />
    </uni-forms-item>
    <uni-forms-item name="password">
      <uni-easyinput
        v-model="formData.password"
        type="password"
        placeholder="请输入密码"
        :input-border="false"
        placeholder-style="color: #C3C3C5"
      />
    </uni-forms-item>
    <view class="agreement">
      <radio @click="onAgreeClick" :checked="isAgree" color="#16C2A3" />
      我已同意
      <text class="link">用户协议</text>
      及
      <text class="link">隐私协议</text>
    </view>

    <button @click="onFormSubmit" class="uni-button">登 录</button>
    <navigator hover-class="none" class="uni-navigator" url=" ">
      忘记密码?
    </navigator>
  </uni-forms>
</template>

<script>
  export default {
    options: {
      styleIsolation: 'shared',
    },
  }
</script>

<style lang="scss">
  @import './styles.scss';
</style>

1 人点赞