危险

为之则易,不为则难

0%

09_登录功能

今日目标

✔ 登录功能。

基础布局

  1. 路由跳转,views/layout/components/app-topnav.vue
1
<li><RouterLink to="/login">请先登录</RouterLink></li>
  1. 封装头部组件,views/login/components/login-header.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
47
48
49
50
51
52
53
54
<script setup lang="ts" name="LoginHeader"></script>
<template>
<header class="login-header">
<div class="container">
<h1 class="logo"><RouterLink to="/">小兔鲜</RouterLink></h1>
<h3 class="sub"><slot></slot></h3>
<RouterLink class="entry" to="/">
进入网站首页
<i class="iconfont icon-angle-right"></i>
<i class="iconfont icon-angle-right"></i>
</RouterLink>
</div>
</header>
</template>

<style scoped lang="less">
.login-header {
background: #fff;
border-bottom: 1px solid #e4e4e4;
.container {
display: flex;
align-items: flex-end;
justify-content: space-between;
}
.logo {
width: 200px;
a {
display: block;
height: 132px;
width: 100%;
text-indent: -9999px;
background: url(@/assets/images/logo.png) no-repeat center 18px / contain;
}
}
.sub {
flex: 1;
font-size: 24px;
font-weight: normal;
margin-bottom: 38px;
margin-left: 20px;
color: #666;
}
.entry {
width: 120px;
margin-bottom: 38px;
font-size: 16px;
i {
font-size: 14px;
color: @xtxColor;
letter-spacing: -5px;
}
}
}
</style>
  1. 底部组件,views/login/components/login-footer.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
<script lang="ts" setup name="LoginFooter"></script>
<template>
<footer class="login-footer">
<div class="container">
<p>
<a href="javascript:;">关于我们</a>
<a href="javascript:;">帮助中心</a>
<a href="javascript:;">售后服务</a>
<a href="javascript:;">配送与验收</a>
<a href="javascript:;">商务合作</a>
<a href="javascript:;">搜索推荐</a>
<a href="javascript:;">友情链接</a>
</p>
<p>CopyRight &copy; 小兔鲜儿</p>
</div>
</footer>
</template>

<style scoped lang="less">
.login-footer {
padding: 30px 0 50px;
background: #fff;
p {
text-align: center;
color: #999;
padding-top: 20px;
a {
line-height: 1;
padding: 0 10px;
color: #999;
display: inline-block;
~ a {
border-left: 1px solid #ccc;
}
}
}
}
</style>
  1. 登录组件渲染,views/login/index.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
47
48
49
50
51
52
53
54
55
56
57
<script lang="ts" setup name="Login">
import LoginHeader from './components/login-header.vue'
import LoginFooter from './components/login-footer.vue'
</script>
<template>
<LoginHeader>欢迎登录</LoginHeader>
<section class="login-section">
<div class="wrapper">
<nav>
<a href="javascript:;">账户登录</a>
<a href="javascript:;">扫码登录</a>
</nav>
</div>
</section>
<LoginFooter />
</template>

<style scoped lang="less">
.login-section {
background: url(@/assets/images/login-bg.png) no-repeat center / cover;
height: 488px;
position: relative;
.wrapper {
width: 380px;
background: #fff;
min-height: 400px;
position: absolute;
left: 50%;
top: 54px;
transform: translate3d(100px, 0, 0);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.15);
nav {
height: 55px;
border-bottom: 1px solid #f5f5f5;
display: flex;
padding: 0 40px;
text-align: right;
align-items: center;
a {
flex: 1;
line-height: 1;
display: inline-block;
font-size: 18px;
position: relative;
&:first-child {
border-right: 1px solid #f5f5f5;
text-align: left;
}
&.active {
color: @xtxColor;
font-weight: bold;
}
}
}
}
}
</style>

切换效果

  1. 准备结构和样式,views/login/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<section class="login-section">
<div class="wrapper">
<nav>
<a class="active" href="javascript:;">账户登录</a>
<a href="javascript:;">扫码登录</a>
</nav>

<!-- 表单 -->
<div v-if="false" class="account-box">表单</div>
<!-- 二维码 -->
<div v-else class="qrcode-box">
<img src="@/assets/images/qrcode.jpg" alt="" />
<p>打开 <a href="javascript:;">小兔鲜App</a> 扫码登录</p>
</div>
</div>
</section>
1
2
3
4
5
6
7
8
9
10
11
12
// 二维码容器
.qrcode-box {
text-align: center;
padding-top: 40px;
p {
margin-top: 20px;
a {
color: @xtxColor;
font-size: 16px;
}
}
}
  1. 控制账户登录和扫码登录的切换逻辑,views/login/index.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
<script lang="ts" setup name="Login">
import LoginHeader from './components/login-header.vue'
import LoginFooter from './components/login-footer.vue'
import { ref } from 'vue'
const activeName = ref<'account' | 'qrcode'>('account')
</script>
<template>
<LoginHeader>欢迎登录</LoginHeader>
<section class="login-section">
<div class="wrapper">
<nav>
<a href="javascript:;" :class="{ active: activeName === 'account' }" @click="activeName = 'account'">账户登录</a>
<a href="javascript:;" :class="{ active: activeName === 'qrcode' }" @click="activeName = 'qrcode'">扫码登录</a>
</nav>

<!-- 表单 -->
<div v-if="activeName === 'account'" class="account-box">表单</div>
<!-- 二维码 -->
<div v-else class="qrcode-box">
<img src="@/assets/images/qrcode.jpg" alt="" />
<p>打开 <a href="javascript:;">小兔鲜App</a> 扫码登录</p>
</div>
</div>
</section>
<LoginFooter />
</template>

表单组件

  1. 准备基本结构,views/login/component/login-form.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
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
<script lang="ts" setup name="LoginForm"></script>
<template>
<div class="account-box">
<div class="toggle">
<a href="javascript:;" v-if="true"> <i class="iconfont icon-user"></i> 使用账号登录 </a>
<a href="javascript:;" v-else> <i class="iconfont icon-msg"></i> 使用短信登录 </a>
</div>
<div class="form">
<template v-if="true">
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<input type="text" placeholder="请输入用户名或手机号" />
</div>
<!-- <div class="error"><i class="iconfont icon-warning" />请输入手机号</div> -->
</div>
<div class="form-item">
<div class="input">
<i class="iconfont icon-lock"></i>
<input type="password" placeholder="请输入密码" />
</div>
</div>
</template>
<template v-else>
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<input type="text" placeholder="请输入手机号" />
</div>
</div>
<div class="form-item">
<div class="input">
<i class="iconfont icon-code"></i>
<input type="password" placeholder="请输入验证码" />
<span class="code">发送验证码</span>
</div>
</div>
</template>
<div class="form-item">
<div class="agree">
<span>我已同意</span>
<a href="javascript:;">《隐私条款》</a>
<span></span>
<a href="javascript:;">《服务条款》</a>
</div>
</div>
<a href="javascript:;" class="btn">登录</a>
</div>
<div class="action">
<img src="https://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_7.png" alt="" />
<div class="url">
<a href="javascript:;">忘记密码</a>
<a href="javascript:;">免费注册</a>
</div>
</div>
</div>
</template>

<style scoped lang="less">
// 账号容器
.account-box {
.toggle {
padding: 15px 40px;
text-align: right;
a {
color: @xtxColor;
i {
font-size: 14px;
}
}
}
.form {
padding: 0 40px;
&-item {
margin-bottom: 28px;
.input {
position: relative;
height: 36px;
> i {
width: 34px;
height: 34px;
background: #cfcdcd;
color: #fff;
position: absolute;
left: 1px;
top: 1px;
text-align: center;
line-height: 34px;
font-size: 18px;
}
input {
padding-left: 44px;
border: 1px solid #cfcdcd;
height: 36px;
line-height: 36px;
width: 100%;
&.error {
border-color: @priceColor;
}
&.active,
&:focus {
border-color: @xtxColor;
}
}
.code {
position: absolute;
right: 1px;
top: 1px;
text-align: center;
line-height: 34px;
font-size: 14px;
background: #f5f5f5;
color: #666;
width: 90px;
height: 34px;
cursor: pointer;
}
}
> .error {
position: absolute;
font-size: 12px;
line-height: 28px;
color: @priceColor;
i {
font-size: 14px;
margin-right: 2px;
}
}
}
.agree {
a {
color: #069;
}
}
.btn {
display: block;
width: 100%;
height: 40px;
color: #fff;
text-align: center;
line-height: 40px;
background: @xtxColor;
&.disabled {
background: #cfcdcd;
}
}
}
.action {
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
img {
cursor: pointer;
}
.url {
a {
color: #999;
margin-left: 10px;
}
}
}
}
</style>
  1. 引入并渲染,views/login/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
<script lang="ts" setup name="Login">
import LoginHeader from './components/login-header.vue'
import LoginFooter from './components/login-footer.vue'
import LoginForm from './components/login-form.vue'
import { ref } from 'vue'
const activeName = ref<'account' | 'qrcode'>('account')
</script>

<!-- 表单 -->
<div v-if="activeName === 'account'" class="account-box">
<LoginForm></LoginForm>
</div>
  1. 控制短信登录/账号登录的切换效果,views/login/components/login-form.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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<script lang="ts" setup name="LoginForm">
import { ref } from 'vue'
// #1
const type = ref<'account' | 'mobile'>('account')
</script>
<template>
<div class="account-box">
<div class="toggle">
<!-- #2 -->
<a href="javascript:;" @click="type = 'account'" v-if="type === 'mobile'"> <i class="iconfont icon-user"></i> 使用账号登录 </a>
<a href="javascript:;" @click="type = 'mobile'" v-else> <i class="iconfont icon-msg"></i> 使用短信登录 </a>
</div>
<div class="form">
<!-- 账号登录 -->
<!-- #3 -->
<template v-if="type === 'account'">
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<input type="text" placeholder="请输入用户名或手机号" />
</div>
<!-- <div class="error"><i class="iconfont icon-warning" />请输入手机号</div> -->
</div>
<div class="form-item">
<div class="input">
<i class="iconfont icon-lock"></i>
<input type="password" placeholder="请输入密码" />
</div>
</div>
</template>
<!-- 手机登录 -->
<!-- #4 -->
<template v-else>
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<input type="text" placeholder="请输入手机号" />
</div>
</div>
<div class="form-item">
<div class="input">
<i class="iconfont icon-code"></i>
<input type="password" placeholder="请输入验证码" />
<span class="code">发送验证码</span>
</div>
</div>
</template>
<div class="form-item">
<div class="agree">
<span>我已同意</span>
<a href="javascript:;">《隐私条款》</a>
<span></span>
<a href="javascript:;">《服务条款》</a>
</div>
</div>
<a href="javascript:;" class="btn">登录</a>
</div>
<div class="action">
<img src="https://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_7.png" alt="" />
<div class="url">
<a href="javascript:;">忘记密码</a>
<a href="javascript:;">免费注册</a>
</div>
</div>
</div>
</template>

复选框组件

  1. 准备组件,components/checkbox/index.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
<script lang="ts" setup name="XtxCheckbox"></script>

<template>
<div class="xtx-checkbox">
<i class="iconfont icon-checked"></i>
<i class="iconfont icon-unchecked"></i>
<span>提示文本</span>
</div>
</template>

<style scoped lang="less">
.xtx-checkbox {
display: inline-block;
margin-right: 2px;
.icon-checked {
color: @xtxColor;
~ span {
color: @xtxColor;
}
}
i {
position: relative;
top: 1px;
}
span {
margin-left: 2px;
}
}
</style>
  1. 全局注册,components/index.ts
1
2
3
4
5
6
import XtxCheckbox from '@/components/checkbox/index.vue'
export default {
install(app: App) {
app.component('XtxCheckbox', XtxCheckbox)
},
}
  1. 增加全局类型,global.d.ts
1
2
3
4
5
6
import XtxCheckbox from '@/components/checkbox/index.vue'
declare module 'vue' {
export interface GlobalComponents {
XtxCheckbox: typeof XtxCheckbox
}
}
  1. 渲染组件,views/login/components/login-form.vue
1
2
3
4
5
6
7
8
9
<!-- 复选框 -->
<div class="form-item">
<div class="agree">
<XtxCheckbox v-model="isAgree">我已同意</XtxCheckbox>
<a href="javascript:;">《隐私条款》</a>
<span></span>
<a href="javascript:;">《服务条款》</a>
</div>
</div>
  1. 控制渲染状态,components/xtx-checkbox.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
<script lang="ts" setup name="XtxCheckbox">
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
})

const emit = defineEmits<{
(e: 'update:modelValue', vlaue: boolean): void
(e: 'change', vlaue: boolean): void
}>()

const change = () => {
emit('update:modelValue', !props.modelValue)
emit('change', !props.modelValue)
}
</script>

<template>
<div class="xtx-checkbox" @click="change">
<i v-if="modelValue" class="iconfont icon-checked"></i>
<i v-else class="iconfont icon-unchecked"></i>
<span><slot></slot></span>
</div>
</template>

<style scoped lang="less">
.xtx-checkbox {
display: inline-block;
margin-right: 2px;
.icon-checked {
color: @xtxColor;
~ span {
color: @xtxColor;
}
}
i {
position: relative;
top: 1px;
}
span {
margin-left: 2px;
}
}
</style>

消息提示组件

基础封装

组件功能分析。

  • 固定顶部显示,有三种类型:成功 success,错误 error,警告 warning。

  • 显示消息提示时需要动画从上滑入且淡出。

  • 组件使用的方式不够便利,封装成工具函数方式。

  1. 基本结构,components/message/message.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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<script lang="ts" setup name="XtxMessage">
import { PropType } from 'vue'

defineProps({
type: {
type: String as PropType<'success' | 'error' | 'warning'>,
default: 'success',
},
text: {
type: String,
},
})

// 定义一个对象,包含三种情况的样式,对象key就是类型字符串
const style = {
warning: {
icon: 'icon-warning',
color: '#E6A23C',
backgroundColor: 'rgb(253, 246, 236)',
borderColor: 'rgb(250, 236, 216)',
},
error: {
icon: 'icon-shanchu',
color: '#F56C6C',
backgroundColor: 'rgb(254, 240, 240)',
borderColor: 'rgb(253, 226, 226)',
},
success: {
icon: 'icon-queren2',
color: '#67C23A',
backgroundColor: 'rgb(240, 249, 235)',
borderColor: 'rgb(225, 243, 216)',
},
}
</script>

<template>
<div class="xtx-message" :style="style[type]">
<i class="iconfont" :class="style[type].icon"></i>
<span class="text">{{text}}</span>
</div>
</template>

<style scoped lang="less">
.xtx-message {
width: 300px;
height: 50px;
position: fixed;
z-index: 9999;
left: 50%;
margin-left: -150px;
top: 25px;
line-height: 50px;
padding: 0 25px;
border: 1px solid #e4e4e4;
background: #f5f5f5;
color: #999;
border-radius: 4px;
i {
margin-right: 4px;
vertical-align: middle;
}
.text {
vertical-align: middle;
}
}
</style>
  1. 全局注册,components/index.ts
1
2
import XtxMessage from '@/components/message/message.vue'
app.component('XtxMessage', XtxMessage)
  1. 定义类型,global.d.ts
1
2
3
4
5
6
7
8
import XtxMessage from '@/components/message/message.vue'

declare module 'vue' {
export interface GlobalComponents {
XtxMessage: typeof XtxMessage
}
}
export {}
  1. 测试,views/playground/index.vue
1
2
3
4
5
6
7
8
9
<script setup name="PlayGround" lang="ts">
import { ref } from 'vue'

const bBar = ref(false)
</script>
<template>
<button @click="bBar = !bBar">显示/隐藏</button>
<XtxMessage v-if="bBar" type="error" text="提示消息" />
</template>

h 与 render

views/playground/index.vue

1
2
3
4
5
6
7
8
9
10
11
<template>
<div class="box">
<h1 title="标题" id="box" class="content">我是标题</h1>
</div>
</template>

<style scoped lang="less">
.content {
color: red;
}
</style>

使用 h 和 render 函数来模拟上面的效果。

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
<script lang="ts" setup name="PlayGround">
// h 等价于 createVNode
// createVNode 作用:创建虚拟 DOM (一个 JS 对象, 可以模拟真实 DOM 结构)
import { h, onMounted, render } from 'vue'
// 参数 1:创建的虚拟 DOM 的节点类型,比如 div、h1、a、img
// 参数 2:虚拟 DOM 拥有的属性,是一个对象
// 参数 3:虚拟 DOM 节点的内容
// <h1 title="标题" id="box" class="content">我是标题</h1>
const vNode = h('h1', { title: '标题', id: 'box', className: 'content' }, '我是标题')

onMounted(() => {
// 参数 1:虚拟 DOM
// 参数 2:真实的 DOM,虚拟 DOM 的挂载点
render(vNode, document.querySelector('.box')!)
})
</script>

<template>
<div class="box"></div>
</template>

<style scoped lang="less">
:deep(.content) {
color: red;
}
</style>

函数封装

目前,message 组件还不够好用,主要问题有 2 个:使用麻烦;样式会受到父元素的影响。

  1. components/message/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { h, render } from 'vue'
import XtxMessage from './message.vue'

type Props = {
type: 'success' | 'error' | 'warning'
text: string
}

const div = document.createElement('div')
div.setAttribute('class', 'xtx-message-container')
document.body.appendChild(div)

export default function Message({ type, text }: Props) {
const vNode = h(XtxMessage, { type, text })
render(vNode, div)
}
  1. 测试,views/playground/index.vue
1
2
3
4
5
6
7
8
9
10
<script setup name="PlayGround" lang="ts">
import Message from '@/components/message/index'

const handleClick = () => {
Message({ type: 'success', text: 'Hello World' })
}
</script>
<template>
<button @click="handleClick">显示/隐藏</button>
</template>
  1. 延迟 2 秒后自动关闭,components/message/index.ts
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 { h, render } from 'vue'
import XtxMessage from './message.vue'

type Props = {
type: 'success' | 'error' | 'warning'
text: string
// #1
duration?: number
}

const div = document.createElement('div')
div.setAttribute('class', 'xtx-message-container')
document.body.appendChild(div)
// #2
let timer: number = -1

export default function Message({ type, text, duration = 2000 }: Props) {
const vNode = h(XtxMessage, { type, text })
render(vNode, div)

// #3
clearTimeout(timer)
// #4
timer = window.setTimeout(() => {
render(null, div)
}, duration)
}

动画效果优化

components/message/message.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
<script lang="ts" setup name="XtxMessage">
// ...
// #1
const isShow = ref(false)
onMounted(() => {
isShow.value = true
})
</script>
<template>
<!-- #2 -->
<Transition name="down">
<!-- #3 -->
<div class="xtx-message" :style="style[type]" v-show="isShow">
<i class="iconfont" :class="style[type].icon"></i>
<span class="text">{{ text }}</span>
</div>
</Transition>
</template>

<style scoped lang="less">
// ...
// #4
.down {
&-enter {
&-from {
transform: translate3d(0, -75px, 0);
opacity: 0;
}
&-active {
transition: all 0.5s;
}
&-to {
transform: none;
opacity: 1;
}
}
}
</style>

通用方法封装

components/message/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Message.error = function (text: string, duration = 2000) {
Message({
type: 'error',
text,
duration,
})
}
Message.success = function (text: string, duration = 2000) {
Message({
type: 'success',
text,
duration,
})
}
Message.warning = function (text: string, duration = 2000) {
Message({
type: 'warning',
text,
duration,
})
}

src/views/playground/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
<script setup name="PlayGround" lang="ts">
import Message from '@/components/message/index'

const handleClick = () => {
Message.success('Hello World')
// Message.error('Hello World')
// Message.warning('Hello World')
}
</script>
<template>
<button @click="handleClick">显示/隐藏</button>
</template>

登录功能

收集表单数据

views/login/components/login-form.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
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
<script lang="ts" setup name="LoginForm">
import { ref } from 'vue'
const type = ref<'account' | 'mobile'>('account')
// !#1
const isMsgLogin = ref(false)
// !#2
const form = ref({
account: '',
password: '',
isAgree: false,
})
const login = () => {
console.log(form.value)
}
</script>
<template>
<div class="account-box">
<div class="toggle">
<a href="javascript:;" @click="type = 'account'" v-if="type === 'mobile'"> <i class="iconfont icon-user"></i> 使用账号登录 </a>
<a href="javascript:;" @click="type = 'mobile'" v-else> <i class="iconfont icon-msg"></i> 使用短信登录 </a>
</div>
<div class="form">
<!-- 账号登录 -->
<template v-if="type === 'account'">
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<!-- //!#3 -->
<input type="text" placeholder="请输入用户名或手机号" v-model="form.account" />
</div>
<!-- <div class="error"><i class="iconfont icon-warning" />请输入手机号</div> -->
</div>
<div class="form-item">
<div class="input">
<i class="iconfont icon-lock"></i>
<!-- //!#4 -->
<input type="password" placeholder="请输入密码" v-model="form.password" />
</div>
</div>
</template>
<!-- 手机登录 -->
<template v-else>
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<input type="text" placeholder="请输入手机号" />
</div>
</div>
<div class="form-item">
<div class="input">
<i class="iconfont icon-code"></i>
<input type="password" placeholder="请输入验证码" />
<span class="code">发送验证码</span>
</div>
</div>
</template>
<div class="form-item">
<div class="agree">
<!-- //!#5 -->
<XtxCheckbox v-model="form.isAgree">我已同意</XtxCheckbox>
<a href="javascript:;">《隐私条款》</a>
<span></span>
<a href="javascript:;">《服务条款》</a>
</div>
</div>
<a href="javascript:;" class="btn" @click="login">登录</a>
</div>
<div class="action">
<img src="https://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_7.png" alt="" />
<div class="url">
<a href="javascript:;">忘记密码</a>
<a href="javascript:;">免费注册</a>
</div>
</div>
</div>
</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
<script lang="ts" setup name="LoginForm">
import { ref } from 'vue'
// #1
import Message from '@/components/message'
const type = ref<'account' | 'mobile'>('account')
const isMsgLogin = ref(false)
const form = ref({
account: '',
password: '',
isAgree: false,
})
// #2
const login = () => {
if (form.value.account === '') {
Message({ type: 'error', text: '用户名或手机号不能为空' })
return
}
if (form.value.password === '') {
Message({ type: 'error', text: '密码不能为空' })
return
}
if (!form.value.isAgree) {
Message({ type: 'error', text: '请同意许可' })
return
}
console.log('通过校验,可以发送请求')
}
</script>

Pinia 状态管理

  1. 新建文件,src\store\modules\user.ts
1
2
3
4
5
6
7
import { defineStore } from 'pinia'

export default defineStore({
id: 'user',
// 状态
state: () => ({}),
})
  1. 修改文件,src\store\index.ts
1
2
3
4
5
6
7
import useUserStore from './modules/user'

export default function useStore() {
return {
user: useUserStore(),
}
}
  1. 调试工具检查 Pinia。

发送请求

  1. 定义类型,src/types/data.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
export interface Profile {
id: string
account: string
mobile: string
token: string
avatar: string
nickname: string
gender: string
birthday?: string
cityCode: string
provinceCode: string
profession: string
}
  1. 发送请求,获取用户数据,store/modules/user.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { ApiRes, Profile } from '@/types/data'
import request from '@/utils/request'
import { defineStore } from 'pinia'

export default defineStore({
id: 'user',
state: () => ({
// 用户信息
profile: {} as Profile,
}),
actions: {
// 用户名和密码登录
async login(account: string, password: string) {
const res = await request.post<ApiRes<Profile>>('/login', {
account,
password,
})
this.profile = res.data.result
},
},
})
  1. 组件中发送请求进行登录,views/login/components/login-form.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
import { ref } from 'vue'
import Message from '@/components/message'
import useStore from '@/store'
import { useRouter } from 'vue-router'
const { user } = useStore()
const router = useRouter()
const type = ref<'account' | 'mobile'>('account')
const form = ref({
account: 'xiaotuxian001',
password: '123456',
isAgree: true,
})
const login = async () => {
if (form.value.account === '') {
Message({ type: 'error', text: '用户名或手机号不能为空' })
return
}
if (form.value.password === '') {
Message({ type: 'error', text: '密码不能为空' })
return
}
if (!form.value.isAgree) {
Message({ type: 'error', text: '请同意许可' })
return
}
// #mark
try {
await user.login(form.value.account, form.value.password)
Message({ type: 'success', text: '登录成功' })
router.push('/')
} catch {
Message.error('用户名或者密码错误')
}
}

个人信息

从 Pinia 中获取个人信息并渲染,views/layout/components/app-topnav.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
<script lang="ts" setup name="AppTopnav">
import useStore from '@/store'
const { user } = useStore()
</script>

<template>
<nav class="app-topnav">
<div class="container">
<ul>
<template v-if="user.profile.token">
<li>
<a href="javascript:;"><i class="iconfont icon-user"></i>{{ user.profile.nickname }}</a>
</li>
<li><a href="javascript:;">退出登录</a></li>
</template>
<template v-else>
<li><RouterLink to="/login">请先登录</RouterLink></li>
<li><a href="javascript:;">免费注册</a></li>
</template>
<li><a href="javascript:;">我的订单</a></li>
<li><a href="javascript:;">会员中心</a></li>
<li><a href="javascript:;">帮助中心</a></li>
<li><a href="javascript:;">关于我们</a></li>
<li>
<a href="javascript:;"><i class="iconfont icon-phone"></i>手机版</a>
</li>
</ul>
</div>
</nav>
</template>

XtxNumbox

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
<!-- 支持输入 -->
<script lang="ts" setup name="XtxNumbox">
import { ComponentInternalInstance, getCurrentInstance } from 'vue'
// const { ctx: _this }: any = getCurrentInstance()
const { proxy } = getCurrentInstance() as ComponentInternalInstance
const props = defineProps({
modelValue: {
type: Number,
default: 1,
},
min: {
type: Number,
default: 1,
},
max: {
type: Number,
default: 20,
},
showLabel: {
type: Boolean,
default: false,
},
})

const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()

const add = () => {
if (props.modelValue >= props.max) return
emit('update:modelValue', props.modelValue + 1)
}

const sub = () => {
if (props.modelValue <= props.min) return
emit('update:modelValue', props.modelValue - 1)
}

const handleChange = (e: Event) => {
const element = e.target as HTMLInputElement
let value = +element.value
if (isNaN(value)) value = 1
if (value >= props.max) value = props.max
if (value <= props.min) value = props.min
emit('update:modelValue', value)
// _this.$forceUpdate()
proxy?.$forceUpdate()
}
</script>
<template>
<div class="xtx-numbox">
233{{ modelValue }}
<div class="label" v-if="showLabel"><slot>数量</slot></div>
<div class="numbox">
<a href="javascript:;" @click="sub">-</a>
<input type="text" :value="modelValue" @change="handleChange($event)" />
<a href="javascript:;" @click="add">+</a>
</div>
</div>
</template>

表单校验

基本使用

如果组件库在表单校验方便支持的不好, 或者你压根不准备用任何组件库,都可以用 vee-validate 实现一种通用的表单校验。

  1. 安装。
1
yarn add vee-validate
  1. 导入函数,login/components/login-form.vue
1
import { useField, useForm } from 'vee-validate'
  1. 通过 useForm 提供校验规则。
1
2
3
4
5
6
7
8
9
10
11
12
13
useForm({
validationSchema: {
account(value: string) {
// value 是将来使用该规则的表单元素的值
// 1. 必填
// 2. 6-20 个字符,需要以字母开头
// 如何反馈校验成功还是失败,返回 true 才是成功,其他情况失败,返回失败原因。
if (!value) return '请输入用户名'
if (!/^[a-zA-Z]\w{5,19}$/.test(value)) return '字母开头且6-20个字符'
return true
},
},
})
  1. 通过 useField 提供 value 值和错误信息。
1
const { value, errorMessage } = useField('account')
  1. 与 input 框双向绑定。
1
2
3
4
<div class='input'>
<i class='iconfont icon-user'></i>
<input type='text' v-model='value' placeholder='请输入用户名或手机号' />
</div>
  1. 显示错误。
1
2
3
4
<div class='error' v-if='errorMessage'>
<i class='iconfont icon-warning' />
{{ errorMessage }}
</div>

完整表单校验

  1. 提供完整的表单校验规则。
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
// 表单校验
useForm({
// 提供校验规则
validationSchema: {
account: (value: string) => {
// 校验的 value 值
// value 是将来使用该规则的表单元素的值
// 1. 必填
// 2. 6-20 个字符,需要以字母开头
// 如何反馈校验成功还是失败,返回 true 才是成功,其他情况失败,返回失败原因。
if (!value) return '请输入用户名'
if (!/^[a-zA-Z]\w{5,19}$/.test(value)) return '字母开头且6-20个字符'
return true
},
password: (value: string) => {
if (!value) return '请输入密码'
if (!/^\w{6,12}$/.test(value)) return '密码必须是6-24位字符'
return true
},
isAgree: (value: boolean) => {
if (!value) return '请同意隐私条款'
return true
},
},
})
  1. 给每一个字段提供校验。
1
2
3
const { value: account, errorMessage: accountError } = useField<string>('account')
const { value: password, errorMessage: passwordError } = useField<string>('password')
const { value: isAgree, errorMessage: isAgreeError } = useField<boolean>('isAgree')
  1. 绑定表单元素。
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
<template>
<div class="account-box">
<div class="toggle">
<a href="javascript:;" @click="type = 'account'" v-if="type === 'mobile'"> <i class="iconfont icon-user"></i> 使用账号登录 </a>
<a href="javascript:;" @click="type = 'mobile'" v-else> <i class="iconfont icon-msg"></i> 使用短信登录 </a>
</div>
<div class="form">
<!-- 账号登录 -->
<template v-if="type === 'account'">
<!-- //!#1 -->
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<input type="text" placeholder="请输入用户名或手机号" v-model="account" />
</div>
<div class="error" v-if="accountError"><i class="iconfont icon-warning" />{{ accountError }}</div>
</div>
<!-- //!#2 -->
<div class="form-item">
<div class="input">
<i class="iconfont icon-lock"></i>
<input type="password" placeholder="请输入密码" v-model="password" />
</div>
<div class="error" v-if="passwordError"><i class="iconfont icon-warning" />{{ passwordError }}</div>
</div>
</template>
<!-- 手机登录 -->
<template v-else>
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<input type="text" placeholder="请输入手机号" />
</div>
</div>
<div class="form-item">
<div class="input">
<i class="iconfont icon-code"></i>
<input type="password" placeholder="请输入验证码" />
<span class="code">发送验证码</span>
</div>
</div>
</template>
<!-- //!#3 -->
<div class="form-item">
<div class="agree">
<XtxCheckbox v-model="isAgree">我已同意</XtxCheckbox>
<a href="javascript:;">《隐私条款》</a>
<span></span>
<a href="javascript:;">《服务条款》</a>
</div>
<div class="error" v-if="isAgreeError"><i class="iconfont icon-warning" />{{ isAgreeError }}</div>
</div>
<a href="javascript:;" class="btn" @click="login">登录</a>
</div>
<div class="action">
<img src="https://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_7.png" alt="" />
<div class="url">
<a href="javascript:;">忘记密码</a>
<a href="javascript:;">免费注册</a>
</div>
</div>
</div>
</template>

登录时校验

  1. 导入函数。
1
import { useField, useForm } from 'vee-validate'
  1. 登录时,对整个表单进行校验。
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
const { validate } = useForm({
initialValues: {
account: 'xiaotuxian001',
password: '123456',
isAgree: true,
},
validationSchema: {
account: (value: string) => {
// 校验的value值
// value是将来使用该规则的表单元素的值
// 1. 必填
// 2. 6-20个字符,需要以字母开头
// 如何反馈校验成功还是失败,返回true才是成功,其他情况失败,返回失败原因。
if (!value) return '请输入用户名'
if (!/^[a-zA-Z]\w{5,19}$/.test(value)) return '字母开头且6-20个字符'
return true
},
password: (value: string) => {
if (!value) return '请输入密码'
if (!/^\w{6,12}$/.test(value)) return '密码必须是6-24位字符'
return true
},
isAgree: (value: boolean) => {
if (!value) return '请同意隐私条款'
return true
},
},
})

const login = async () => {
const res = await validate()
if (!res.valid) return
try {
// 注意这儿换成了 account.value 和 password.value
await user.login(account.value, password.value)
Message.success('登录成功')
router.push('/')
} catch {
Message.error('账号或者密码错误')
}
}

短信登录

表单校验

  1. useForm 增加了两个校验。
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
// 表单校验
const { validate } = useForm({
// 提供校验规则
validationSchema: {
account: (value: string) => {
// 校验的value值
// value是将来使用该规则的表单元素的值
// 1. 必填
// 2. 6-20个字符,需要以字母开头
// 如何反馈校验成功还是失败,返回true才是成功,其他情况失败,返回失败原因。
if (!value) return '请输入用户名'
if (!/^[a-zA-Z]\w{5,19}$/.test(value)) return '字母开头且6-20个字符'
return true
},
password: (value: string) => {
if (!value) return '请输入密码'
if (!/^\w{6,12}$/.test(value)) return '密码必须是6-24位字符'
return true
},
isAgree: (value: boolean) => {
if (!value) return '请同意隐私条款'
return true
},
mobile: (value: string) => {
if (!value) return '请输入手机号'
if (!/^1[3-9]\d{9}$/.test(value)) return '手机号格式错误'
return true
},
code: (value: string) => {
if (!value) return '请输入验证码'
if (!/^\d{6}$/.test(value)) return '验证码格式错误'
return true
},
},
})
  1. 增加两个 useField。
1
2
const { value: mobile, errorMessage: mobileError } = useField<string>('mobile')
const { value: code, errorMessage: codeError } = useField<string>('code')
  1. 绑定 value 值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template v-else>
<div class="form-item">
<div class="input">
<i class="iconfont icon-user"></i>
<input type="text" v-model="mobile" placeholder="请输入手机号" />
</div>
<div class="error" v-if="mobileError"><i class="iconfont icon-warning" />{{ mobileError }}</div>
</div>
<div class="form-item">
<div class="input">
<i class="iconfont icon-code"></i>
<input type="text" v-model="code" placeholder="请输入验证码" />
<span class="code">发送验证码</span>
</div>
<div class="error" v-if="codeError"><i class="iconfont icon-warning" />{{ codeError }}</div>
</div>
</template>

切换时重置表单校验

重现:去掉 initialValues,点击登录,“我已同意”出现错误提示,切换到短信登录,发现还在。

  1. 通过 useForm 解构 resetForm 方法。
1
2
3
4
5
6
const { validate, resetForm } = useForm({
// 提供校验规则
validationSchema: {
//....
},
})
  1. 监听 type 的变化,重置。
1
2
3
4
5
// 监听type的变化
watch(type, () => {
// 重置表单
resetForm()
})

获取验证码-表单校验

需求:点击获取验证码时候,能够单独对手机号进行格式校验,如果校验不通过,自动获取焦点。

  1. 注册事件。
1
<span class="code" @click="send">发送验证码</span>
  1. 导入函数。
1
const { value: mobile, errorMessage: mobileError, validate: validateMobile } = useField<string>('mobile')
  1. 验证表单。
1
2
3
4
5
6
7
8
9
10
const mobileRef = ref<HTMLInputElement | null>(null)
const send = async () => {
const res = await validateMobile()
if (!res.valid) {
// 校验没通过
mobileRef.value?.focus()
return
}
console.log('发送验证码')
}
  1. 绑定 ref。
1
2
3
4
<div class="input">
<i class="iconfont icon-user"></i>
<input ref="mobileRef" v-model="mobile" type="text" placeholder="请输入手机号" />
</div>

获取验证码-发送请求

  1. 在 actions 中提供方法,store/modules/user.ts
1
2
3
4
5
6
7
8
// 获取手机验证码
async sendMobileMsg(mobile: string) {
await request.get('/login/code', {
params: {
mobile
}
})
}
  1. 表单校验通过时,发送请求,获取验证码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const send = async () => {
// console.log('获取验证码')
// 单独校验手机号
const res = await validMobile()
if (!res.valid) {
// 如果没通过,自动获取手机号的焦点
mobileRef.value!.focus()
return
}
try {
await user.sendMobileMsg(mobile.value)
Message.success('获取验证码成功')
} catch {
Message.error('获取验证码失败')
}
}

配置 Axios 响应拦截器

配置 Axios 进行统一错误提示,utils/request.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 添加响应拦截器
instance.interceptors.response.use(
function (response) {
return response
},
function (error: AxiosError<{ message: string; code: string }>) {
// 泛型表示 error.response.data 的类型
if (!error.response) {
Message.error('网络异常,请稍后重置')
} else {
Message.error(error.response.data.message)
}
return Promise.reject(error)
}
)

获取验证码-倒计时

  1. 提供倒计时逻辑。
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
// #1
const time = ref(0)
let timer = -1
const send = async () => {
// #3
if (time.value > 0) return
const res = await validateMobile()
if (!res.valid) {
// 校验没通过
mobileRef.value?.focus()
return
}
try {
await user.sendMobileMsg(mobile.value)
Message.success('获取验证码成功')
// #2 开启倒计时
time.value = 60
timer = window.setInterval(() => {
time.value--
if (time.value === 0) {
clearInterval(timer)
}
}, 1000)
} catch {
Message.error('获取验证码失败')
}
}
  1. 渲染倒计时内容。
1
2
3
4
5
6
7
<div class="form-item">
<div class="input">
<i class="iconfont icon-code"></i>
<input v-model="code" type="password" placeholder="请输入验证码" />
<span class="code" @click="send"> {{ time === 0 ? '发送验证码' : `${time}s后发送` }} </span>
</div>
</div>

获取验证码-倒计时优化

参考文档,修改登录倒计时代码。

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
const time = ref(0)
const { pause, resume } = useIntervalFn(
() => {
time.value--
if (time.value <= 0) {
pause()
}
},
1000,
{
immediate: false,
}
)

const send = async () => {
if (time.value > 0) return
// console.log('获取验证码')
// 单独校验手机号
const res = await validMobile()
if (!res.valid) {
// 如果没通过,自动获取手机号的焦点
mobileRef.value!.focus()
return
}
await user.sendMobileMsg(mobile.value)
Message.success('获取验证码成功')

// 开启倒计时
time.value = 60
resume()
}

获取验证码-封装

hooks/index.ts

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 { useIntervalFn } from '@vueuse/core'
import { onUnmounted, ref } from 'vue'

/**
* 封装一个倒计时功能
*/
export function useCountDown(count: number = 60) {
const time = ref(0)
const { pause, resume } = useIntervalFn(
() => {
time.value--
if (time.value === 0) {
pause()
}
},
1000,
{ immediate: false }
)

// 组件销毁时清除定时器
onUnmounted(() => {
pause()
})

const start = () => {
time.value = count
resume()
}

return {
time,
start,
}
}

使用,views/login/components/login-form.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// #1
const { time, start } = useCountDown(60)
const send = async () => {
if (time.value > 0) return
// 单独校验手机号
const res = await validMobile()
if (!res.valid) {
// 如果没通过,自动获取手机号的焦点
mobileRef.value!.focus()
return
}
await user.sendMobileMsg(mobile.value)
Message.success('获取验证码成功')
// #2
start()
}

功能完成

  1. 封装短信登录接口,store/modules/user.ts
1
2
3
4
5
6
7
async mobileLogin(mobile: string, code: string) {
const res = await request.post<ApiRes<Profile>>('/login/code', {
mobile,
code
})
this.profile = res.data.result
},
  1. 修改登录逻辑,views/login/components/login-form.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const login = async () => {
const res = await validate()
// #1
// if (!res.valid) return
try {
// #2
if (type.value === 'account') {
if (res.errors.account || res.errors.password || res.errors.isAgree) return
await user.login(account.value, password.value)
} else {
if (res.errors.mobile || res.errors.code || res.errors.isAgree) return
await user.mobileLogin(mobile.value, code.value)
}
Message({ type: 'success', text: '登录成功' })
router.push('/')
} catch {
Message.error('用户名或者密码错误')
}
}
  1. 修改显示效果,views/loyout/components/app-topnav.vue
1
2
3
4
5
6
<li>
<a href="javascript:;">
<i class="iconfont icon-user"></i>
{{ user.profile.nickname || user.profile.account }}
</a>
</li>

数据持久化

在 Pinia 中存储的数据是保存在内存中的,因此一旦刷新页面,Pinia 中的数据就丢失了,因此我们需要把数据保存到 localStorage 中进行持久化。

  1. 封装 storage 的操作,utils/storage.ts
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
import { Profile } from '@/types/data'
const PROFILE_KEY = 'rabbit-profile-92'
// 封装和localstorage相关的api

/**
* 设置个人信息
* @param profile
*/
export function setProfile(profile: Profile): void {
localStorage.setItem(PROFILE_KEY, JSON.stringify(profile))
}

/**
* 获取个人信息
* @returns
*/
export function getProfile(): Profile {
return JSON.parse(localStorage.getItem(PROFILE_KEY) || '{}')
}

/**
* 移除个人信息
*/
export function removeProfile(): void {
localStorage.removeItem(PROFILE_KEY)
}
  1. 保存信息时持久化,store/modules/user.ts
1
2
3
4
5
6
7
8
async login(account: string, password: string) {
const res = await request.post<ApiRes<Profile>>('/login', {
account,
password,
})
this.profile = res.data.result
setProfile(res.data.result)
},
  1. 获取是从 localstorage 中获取。
1
2
3
state: () => ({
profile: getProfile()
}),

退出登录功能

  1. actions 中提供退出方法,store/modules/user.ts
1
2
3
4
logout() {
this.profile = {} as Profile
removeProfile()
}
  1. 退出功能,views/layout/components/app-topnav.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
<script lang="ts" setup name="AppTopnav">
import Message from '@/components/message'
import router from '@/router'
import useStore from '@/store'

const { user } = useStore()
const logout = () => {
user.logout()
router.push('/login')
Message.success('退出成功')
}
</script>
<li><a href="javascript:;" @click="logout">退出登录</a></li>