uni-app(优医咨询)项目实战 - 第 3 天
学习目标:
- 掌握 luch-request 网络请求的用法
- 能够对 Pinia 进行初始化操作
- 掌握创建 Store 及数据操作的步骤
- 能够对 Pinia 数据进行持久化的处理
- 掌握用户登录的实现方法
一、项目启动
从零起步创建项目,完整的静态页面可以从 gitee 仓库获取。
1.1 创建项目
以 HBuilder X 的方式创建项目:
- 项目名称:优医咨询
- Vue 版本:Vue3
- 模板:默认模板
1.1.1 .prettierrc
在项目根目录下创建 .prettierrc
文件,然后添加下述配置选项:
1 2 3 4 5 6 7 8
| { "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
中进行配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| { "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 代码,不仅能精简代码,将来样式的维护也会更方便,这些公共样式是由开发者根据不同的项目需要自定义的,因此不同的项目或者不同开发者定义的公共样式是不一致的,本项目中我定义了以下部分的公共样式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| <!-- 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 本项目定义了一个变量和一个混入,这个混入是用来处理文字溢出的,溢出的部分会显示 …
来代替。
1 2 3 4 5 6 7 8 9 10 11 12
|
$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 引入字体图标
项目中即用到了单色图标,也用到了多色图标:
- 单色图标,将字体图标文件解压缩到 static/fonts 目录中,将 iconfont.css 重命名为 iconfont.scss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| @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 中导入自定义图标的样式文件
1 2 3 4 5 6 7 8 9 10 11
| <!-- App.vue --> <script> // 省略这里的代码... </script>
<style lang="scss"> // 单色图标 @import '@/static/fonts/iconfont.scss' // 以下部分代码省略... </style>
|
字体图标导入成功后要到页面测试一下图标是否能正常显示。
- 关于多色图标的使用在前面课程中已经介绍过了,关于图标的转换部分就不再演示了,我们直接将转换后代码引入项目中
先将生成的多色图标文件 color-fonts.scss
放到项目的根目录中,然后在 App.vue 中导入该文件
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!-- App.vue --> <script> // 省略这里的代码... </script>
<style lang="scss"> // 单色图标 @import '@/static/fonts/iconfont.scss'; // 多色图标 @import './color-fonts.scss';
// 以下部分代码省略... </style>
|
字体图标导入成功后要到页面测试一下图标是否能正常显示。
1.1.5 网站图标
浏览器在加载网页时会在标签页位置展示一个小图标,我们来指定一下这个图标:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <!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"></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
进行二次封装,扩展了基地址、拦截器等业务相关的功能。
- 安装
luch-request
1
| npm install luch-request
|
- 实例化并配置基地址,项目根目录新建 utils/http.js
1 2 3 4 5 6 7 8 9 10 11 12 13
|
import Request from 'luch-request'
const http = new Request({ baseURL: 'https://t1ps66c7na.hk.aircode.run', })
export { http }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <!-- 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
|
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 }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <!-- 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>
|
- 请求加载状态
在发请求之前展示一个加载提示框,请求结束后隐藏这个提示框,该部分的逻辑分别对应请求拦截器和响应拦截器,在请求拦截器中调用 uni.showLoading
在响应拦截器中调用 uni.hideLoading
。
在设置加载提示框之前先来了解一下 luch-request
提供的自定义配置参数的功能,即 custom
属性,该属性的用法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
import Request from 'luch-request'
const http = new Request({ baseURL: 'https://t1ps66c7na.hk.aircode.run', custom: { abc: 123, loading: true } })
|
局部配置了相同的自定义参数时会覆盖全局配置的自定义参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <!-- 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
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
|
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
uni.utils = {
toast(title = ' 数据加载失败!', icon = 'none') { uni.showToast({ title, icon, mask: true, }) }, }
|
这里的方法将来是会被全局引用的,因此在入口 main.js
中导入 utils/utils.js
1 2 3 4 5 6 7 8 9 10 11
| 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 的封装了,用法如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <!-- 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 安装
创建一个 pinia 实例 (根 store) 并将其传递给应用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App' import '@/utils/utils'
export function createApp() { const app = createSSRApp(App) const pinia = createPinia() app.use(pinia) return { app, } }
|
注意事项:
createSSRApp
是配合 SSR 来使用的,其用法与 createApp
相同,在这里 uni-app 为了做跨平台开发所采取的方式,其作用我们就按 createApp
来理解即可。
2.2 Store
在深入研究核心概念之前,我们得知道 Store 是用 defineStore()
定义的,它支持两种语法法格式,分别是选项式 Store 和 组件式 Store,创建一个文件来演示它的使用:
- 选项式(Options)Store
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { defineStore } from 'pinia'
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
页面中通一个计数器来测试它的用法,下面新增的布局相关代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| <!-- 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
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <!-- 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 实例中定义的 state
、getters
、actions
可以直接应用到组件模板当中。
注意事项:
- 定义 Store 时建议(非必须)使用 use + 名称 + Store 格式命名,其中名称也会被当做 ID 出现在调试工具中
- 创建 Store 实例时,实例的名称建议用 名称 + Store 格式命名,避免引入多个 Store 时名称重复的问题
- 组件式(Setup)Store
组合式 Store 用法与选项式 Store 用法最直接的区别就是 defineStore
的第 2 个参数传入是一个函数,而选项式 Store 传入的是一个对象。
另一个区别是在组合式 API 中允许使用 Vue 的组合式函数,如 ref
、computed
、watch
等。
在组件式 Store 中:
ref()
就是 state
属性
computed
就是 getters
function()
就是 actions
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import { defineStore } from 'pinia' import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => { const count = ref(0) const double = computed(() => count.value * 2) function increment() { count.value++ } function decrement() { count.value-- }
return { count, double, increment, decrement } })
|
对比发现组合式 Store 的用法与 Vue 组件的 setup 用法是一致的,咱们项目中会采用这种用法来开发。
2.3 State
State 实际上就是用来共享访问的数据,这些数据会涉及到访问、变更等操作,我们分别来学习 State 的相关操作。
- 访问,State 的数据是使用
reactive
创建的,直接通过 Store 实例属性的方式即可访问,这种访问方式也包括了 getters
的访问。
1 2 3 4 5 6 7 8 9 10 11
| <!-- 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <!-- 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>
|
- 变更
变更 State 的数据有两种方式,一种是直接赋值,另一种是调用 $patch 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| <!-- 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 2
| npm i pinia-plugin-persistedstate
|
- 将插件添加 Pinia 实例上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App' import '@/utils/utils'
export function createApp() { const app = createSSRApp(App) const pinia = createPinia() pinia.use(piniaPluginPersistedstate)
app.use(pinia)
return { app, } }
|
- 将数据持久化存储,为
defineStore
传入第 3 个参数,第 3 个参数是对象类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { defineStore } from 'pinia' import { ref, computed } from 'vue'
export const useCounterStore = defineStore( 'counter', () => { const count = ref(0) const double = computed(() => count.value * 2) function increment() { count.value++ } function decrement() { count.value-- }
return { count, double, increment, decrement } }, { persist: true } )
|
当 count
数据发生改变后就会将数据存入本地存储当中了,但是这种方式有个弊端就是会将所有 State 数据持久化存储,这样会造成不必要的性能损耗,要解决这个问题也非常方便,通过 paths
来指定需要持久化存储的数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import { defineStore } from 'pinia' import { ref, computed } from 'vue'
export const useCounterStore = defineStore( 'counter', () => { const count = ref(0) const double = computed(() => count.value * 2) function increment() { count.value++ } function decrement() { count.value-- }
return { count, double, increment, decrement } }, { persist: { paths: ['count'], }, } )
|
- 配置
以上的用法在一般的 Vue 项目中可以满足基本的开发需要了,但是在 uni-app 中时却需要做一些额外的配置,原因在于 uni-app 中本地存储使用的是 uni.setStorageSync
而插件中使用的是 localStorage.setItem
,为此需要我们自定义配置本地址存储的方法。
使用 createPersistedState
进行全局性配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'
import App from './App' import '@/utils/utils'
export function createApp() { const app = createSSRApp(App) const pinia = createPinia() pinia.use( createPersistedState({ storage: { setItem(key, value) { uni.setStorageSync(key, value) }, getItem(key) { return uni.getStorageSync(key) }, }, }) )
app.use(pinia) return { app, } }
|
createPersistedState
传的参数中 storage
是用来自定义持久化存储方法的,其中 setItem
和 getItem
是内置固定的名称,在进行本地存储时插件内部会自动调用这两个方法,进而调用 uni.setStorageSync
将数据存入本地。
另外存入本地数据的名称默认为 Store 的名称,这个名称也允许自定义,使用 key
来指定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'
import App from './App' import '@/utils/utils'
export function createApp() { const app = createSSRApp(App) const pinia = createPinia() pinia.use( 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 布局模板
- 定义 Tab 标签页切换的基础结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| <!-- 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>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| .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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <!-- 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <!-- 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>
|
- 封装用户名 & 密码组件和短信验证码组件,在当前目录下创建
components/mobile.vue
和 components/password.vue
组件, 组件的布局模板为:
password.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| <!-- 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| <!-- 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| .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); }
radio { transform: scale(0.6); }
: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; }
|
最后将组件导入到页面中,根据索引值来渲染相应的组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| <!-- 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 短信验证码登录
短信息验证码登录的大致流程如下:
- 用户填写正确的手机号码
- 向用户的手机号发送短信
- 用户填写接收到的短信验证码
- 同时提交验证码和手机号
============================================
- 提供了 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| <!-- 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 个属性来控制是否显示 “ 时、分 “,showHour
、showMiniute
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| <!-- /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
,在倒时结束时允许用户重新获取验证码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| <!-- 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| <!-- 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| <!-- 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-item
的 name
属性相对应
- 调用验证方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| <!-- 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 个步骤来实现:
- 封装接口调用的方法,接口文档的地址 查看这里,同时修改接口的基地址和自定义请求头
Authorization
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
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 = { ...config.header, } return config }, function (error) { return Promise.reject(error) } )
|
将接口调用的方法进行统一的管理,放到 services
目录中,然后分模块来对接口的调用进行封装。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
import { http } from '@/utils/http'
export const verifyCodeApi = (data) => { return http.get('/code', { params: data }) }
export const loginByMobileApi = (data) => { return http.post('/login', data) }
|
注意事项,上述代码中将 Api
做为方法名的后缀,如 loginByMobileApi
,目的是方便代码的阅读,一目了然的知道是对接口调用进行的封装。
HBuilder X 使用小技巧:在代码中直接写封装好的 API 方法,根据提示可以快速引用相应的文件模块。
先来调用接口获取短信验证码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| <!-- 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>
|
接收到短信验证码之后再来将表单的全部数据 mobile
和 code
提交给接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <!-- 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>
|
- 记录用户登录状态,通过 Pinia 将登录状态记录下来
新建用于管理用户数据的 Store,通过 token 来记录用户的登录状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { ref } from 'vue' import { defineStore } from 'pinia'
export const useUserStore = defineStore( 'user', () => { const token = ref('')
return { token } }, { persist: { paths: ['token'], }, } )
|
接下来在登录成功后来更新 Pinia 中的 token
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| <!-- 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
方法来验证部分表单数据,其语法如下:
1 2 3 4
| const formRef = ref() formRef.value.validateField(['mobile', ' 数据 2', ' 数据 3'])
|
知道 validateField
的用法后,将来整合到项目中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <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
为不选中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| <!-- 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| <!-- 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 用户名和密码登录
参考代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
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) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
| <!-- 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>
|