今日目标 ✔ 登录功能。
基础布局
路由跳转,views/layout/components/app-topnav.vue
。
1 <li > <RouterLink to ="/login" > 请先登录</RouterLink > </li >
封装头部组件,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 >
底部组件,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 © 小兔鲜儿</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 >
登录组件渲染,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 >
切换效果
准备结构和样式,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 ; } } }
控制账户登录和扫码登录的切换逻辑,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 >
表单组件
准备基本结构,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 > <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 >
引入并渲染,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 >
控制短信登录/账号登录的切换效果,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' const type = ref<'account' | 'mobile' >('account' ) </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 > <input type ="text" placeholder ="请输入用户名或手机号" /> </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 >
复选框组件
准备组件,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 >
全局注册,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) }, }
增加全局类型,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 } }
渲染组件,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 >
控制渲染状态,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 >
消息提示组件 基础封装 组件功能分析。
基本结构,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 , }, }) 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 >
全局注册,components/index.ts
。
1 2 import XtxMessage from '@/components/message/message.vue' app.component('XtxMessage' , XtxMessage)
定义类型,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 {}
测试,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" > import { h, onMounted, render } from 'vue' const vNode = h('h1' , { title : '标题' , id : 'box' , className : 'content' }, '我是标题' ) onMounted(() => { render(vNode, document .querySelector('.box' )!) }) </script > <template > <div class ="box" > </div > </template > <style scoped lang ="less" > :deep (.content) { color: red; } </style >
函数封装 目前,message 组件还不够好用,主要问题有 2 个:使用麻烦;样式会受到父元素的影响。
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) }
测试,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 >
延迟 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 duration?: number } const div = document .createElement('div' )div.setAttribute('class' , 'xtx-message-container' ) document .body.appendChild(div)let timer: number = -1 export default function Message ({ type , text, duration = 2000 }: Props ) { const vNode = h(XtxMessage, { type , text }) render(vNode, div) clearTimeout (timer) 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" > const isShow = ref(false ) onMounted(() => { isShow.value = true }) </script > <template > <Transition name ="down" > <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' ) } </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' ) const isMsgLogin = ref(false ) 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 > <input type ="text" placeholder ="请输入用户名或手机号" v-model ="form.account" /> </div > </div > <div class ="form-item" > <div class ="input" > <i class ="iconfont icon-lock" > </i > <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" > <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' import Message from '@/components/message' const type = ref<'account' | 'mobile' >('account' ) const isMsgLogin = ref(false ) const form = ref({ account : '' , password : '' , isAgree : false , }) 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 状态管理
新建文件,src\store\modules\user.ts
。
1 2 3 4 5 6 7 import { defineStore } from 'pinia' export default defineStore({ id : 'user' , state : () => ({}), })
修改文件,src\store\index.ts
。
1 2 3 4 5 6 7 import useUserStore from './modules/user' export default function useStore ( ) { return { user : useUserStore(), } }
调试工具检查 Pinia。
发送请求
定义类型,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 }
发送请求,获取用户数据,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 }, }, })
组件中发送请求进行登录,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 } 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 { 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) 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 实现一种通用的表单校验。
安装。
导入函数,login/components/login-form.vue
。
1 import { useField, useForm } from 'vee-validate'
通过 useForm 提供校验规则。
1 2 3 4 5 6 7 8 9 10 11 12 13 useForm({ validationSchema : { account (value: string ) { if (!value) return '请输入用户名' if (!/^[a-zA-Z]\w{5,19}$/ .test(value)) return '字母开头且6-20个字符' return true }, }, })
通过 useField 提供 value 值和错误信息。
1 const { value, errorMessage } = useField('account' )
与 input 框双向绑定。
1 2 3 4 <div class ='input' > <i class ='iconfont icon-user' > </i > <input type ='text' v-model ='value' placeholder ='请输入用户名或手机号' /> </div>
显示错误。
1 2 3 4 <div class ='error' v-if ='errorMessage' > <i class ='iconfont icon-warning' /> {{ errorMessage }} </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 useForm({ validationSchema : { account : (value: string ) => { 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 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 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'" > <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 > <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 > <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 import { useField, useForm } from 'vee-validate'
登录时,对整个表单进行校验。
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 ) => { 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 { await user.login(account.value, password.value) Message.success('登录成功' ) router.push('/' ) } catch { Message.error('账号或者密码错误' ) } }
短信登录 表单校验
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 ) => { 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 }, }, })
增加两个 useField。
1 2 const { value : mobile, errorMessage : mobileError } = useField<string >('mobile' )const { value : code, errorMessage : codeError } = useField<string >('code' )
绑定 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,点击登录,“我已同意”出现错误提示,切换到短信登录,发现还在。
通过 useForm 解构 resetForm 方法。
1 2 3 4 5 6 const { validate, resetForm } = useForm({ validationSchema : { }, })
监听 type 的变化,重置。
1 2 3 4 5 watch(type , () => { resetForm() })
获取验证码-表单校验 需求:点击获取验证码时候,能够单独对手机号进行格式校验,如果校验不通过,自动获取焦点。
注册事件。
1 <span class ="code" @click ="send" >发送验证码</span>
导入函数。
1 const { value : mobile, errorMessage : mobileError, validate : validateMobile } = useField<string >('mobile' )
验证表单。
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('发送验证码' ) }
绑定 ref。
1 2 3 4 <div class ="input" > <i class ="iconfont icon-user" > </i > <input ref ="mobileRef" v-model ="mobile" type ="text" placeholder ="请输入手机号" /> </div >
获取验证码-发送请求
在 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const send = async () => { 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 }> ) { if (!error.response) { Message.error('网络异常,请稍后重置' ) } else { Message.error(error.response.data.message) } return Promise .reject(error) } )
获取验证码-倒计时
提供倒计时逻辑。
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 const time = ref(0 )let timer = -1 const send = async () => { if (time.value > 0 ) return const res = await validateMobile() if (!res.valid) { mobileRef.value?.focus() return } try { await user.sendMobileMsg(mobile.value) Message.success('获取验证码成功' ) time.value = 60 timer = window .setInterval(() => { time.value-- if (time.value === 0 ) { clearInterval (timer) } }, 1000 ) } catch { Message.error('获取验证码失败' ) } }
渲染倒计时内容。
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 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 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('获取验证码成功' ) start() }
功能完成
封装短信登录接口,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 },
修改登录逻辑,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() try { 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('用户名或者密码错误' ) } }
修改显示效果,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 中进行持久化。
封装 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' export function setProfile (profile: Profile ): void { localStorage .setItem(PROFILE_KEY, JSON .stringify(profile)) } export function getProfile ( ): Profile { return JSON .parse(localStorage .getItem(PROFILE_KEY) || '{}' ) } export function removeProfile ( ): void { localStorage .removeItem(PROFILE_KEY) }
保存信息时持久化,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) },
获取是从 localstorage 中获取。
1 2 3 state: () => ({ profile : getProfile() }),
退出登录功能
actions 中提供退出方法,store/modules/user.ts
。
1 2 3 4 logout ( ) { this .profile = {} as Profile removeProfile() }
退出功能,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 >