前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >uni-app(优医咨询)项目实战 - 第3天

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

作者头像
程序员朱永胜
发布2024-04-20 11:09:05
1430
发布2024-04-20 11:09:05
举报

学习目标:

  • 掌握 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(?developer/article/2410914/&) 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>
本文参与?腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2024-04-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客?前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体分享计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、项目启动
    • 1.1 创建项目
      • 1.1.1 .prettierrc
      • 1.1.2 配置 tabBar
      • 1.1.3 公共样式
      • 1.1.4 引入字体图标
      • 1.1.5 网站图标
    • 1.2 公共封装
      • 1.2.1 网络请求
      • 1.2.2 轻提示
  • 二、Pinia 状态管理
    • 2.1 安装
      • 2.2 Store
        • 2.3 State
          • 2.4 持久化
          • 三、用户登录
            • 3.1 布局及交互
              • 3.1.1 布局模板
              • 3.1.2 标签切换
            • 3.2 短信验证码登录
              • 3.2.1 倒计时组件
              • 3.2.2 表单数据验证
              • 3.3.3 调用接口
          • 四、作业
            • 4.1 部分表单验证
              • 4.2 是否同意协议
                • 4.3 用户名和密码登录
                相关产品与服务
                验证码
                腾讯云新一代行为验证码(Captcha),基于十道安全栅栏, 为网页、App、小程序开发者打造立体、全面的人机验证。最大程度保护注册登录、活动秒杀、点赞发帖、数据保护等各大场景下业务安全的同时,提供更精细化的用户体验。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
                http://www.vxiaotou.com