【微信小程序】uniapp封装全局登录授权弹窗
项目框架: vite+uni-app+ts
ui: uv-ui
底部弹出的手机号授权登录弹窗,基于 @climblee/uv-ui 的 uv-popup,与 userStore.login、全局方法 showLoginPopup 配合使用。
功能说明
- 通过调用全局方法
showLoginPopup,可在项目中任意页面打开登录授权弹窗,弹窗授权成功后会调用onSuccess中传入的回调方法,可继续执行登录前操作。 - 用户授权成功后调用
userStore.login(phoneCode),再执行resolveLoginPopupSuccess(),并关闭弹窗。 showLoginPopup默认会等待弹窗关闭,请勿在弹窗内执行耗时操作。- 弹窗内点击遮罩关闭,执行
resolveLoginPopupSuccess(),并关闭弹窗。 - 弹窗会根据
getCurrentPagePath()自动注册,请勿手动注册, 使用当前栈顶页的pagePath在注册表中查找实例,只对当前页的弹窗执行open/close。
相关文件
| 路径 | 作用 |
|---|---|
src/utils/modals/loginPopup.ts |
全局登录模块调用方法 |
src/components/login-popup/login-popup.vue |
弹窗 UI 与注册 |
vite.config.ts |
UniViteRootInjector 自动注入 <login-popup /> |
main.ts |
app.component()和挂载showLoginPopup |
src/store/user/index.ts |
login(phoneCode) 登录方法 |
使用方式
1.开发弹窗组件:
示例:
src\components\login-popup
<!-- 全局登录弹窗:通过 showLoginPopup() 打开,登录成功后执行传入的 onSuccess -->
<template>
<uv-popup
ref="popup"
mode="bottom"
@change="onPopupChange($event)"
>
<view class="container">
<view class="text-xl text-center mt-3 font-bold">
{{ appName }}
</view>
<view class="w-[488rpx] mt-4">
<uv-button type="primary" shape="circle" size="large" open-type="getPhoneNumber" @getphonenumber="handleLogin">
立即登录
</uv-button>
</view>
</view>
</uv-popup>
</template>
<script lang="ts" setup>
import { VITE_GLOB_APP_NAME } from '@/configs'
import { useUserStore } from '@/store/user'
import {
closeLoginPopup,
registerLoginPopup,
resolveLoginPopupSuccess,
supersedePending,
} from '@/utils/modals/loginPopup'
import { getCurrentPagePath } from '@/utils'
const popup = ref<{ open?: () => void; close?: () => void } | null>(null)
const appName = JSON.parse(VITE_GLOB_APP_NAME)
onMounted(() => {
const pagePath = getCurrentPagePath()
registerLoginPopup({
pagePath,
open: () => popup.value?.open?.(),
close: () => popup.value?.close?.(),
})
})
onUnmounted(() => {
closeLoginPopup()
})
/** 弹窗被关闭时若仍有未完成的 Promise,视为取消 */
function onPopupChange(e: { show?: boolean }) {
if (!e.show) {
supersedePending()
}
}
async function handleLogin(e: any) {
const userStore = useUserStore()
const detail = e?.detail ?? e
const errMsg = String(detail?.errMsg ?? '')
if (!errMsg.includes('getPhoneNumber:ok')) {
uni.showToast({ title: '需要授权手机号才能登录', icon: 'none' })
return
}
const phoneCode = detail?.code
if (!phoneCode) {
uni.showToast({ title: '未获取到授权码', icon: 'none' })
return
}
try {
// 相关登录方法
await userStore.login(phoneCode)
}
catch (err) {
console.error(err)
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
return
}
await resolveLoginPopupSuccess()
popup.value?.close?.()
}
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 72vh;
padding: 36rpx 30rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
width: 160rpx;
height: 160rpx;
image{
border-radius: 36rpx;
width: 100%;
height: 100%;
}
}
</style>
2.全局注册组件
项目中使用了vite-inset-loader来进行全局组件注入。
- 安装
vite-inset-loader
pnpm add vite-inset-loader -D
# 或
npm i vite-inset-loader -D
# 或
yarn add vite-inset-loader -D
- 在vite.config.ts中注册组件
import path from 'node:path'
import { defineConfig, loadEnv } from 'vite'
import useUni from '@dcloudio/vite-plugin-uni'
import { UniViteRootInjector } from 'vite-inset-loader'
import type { Path } from './@types/auto-page.d.ts'
export default defineConfig(({ mode }) => {
const components = {
LoginPopup: '<login-popup></login-popup>',
}
return {
//...
plugins: [
UniViteRootInjector<Path, typeof components>({
dts: path.resolve(__dirname, '@types/auto-page.d.ts'),
components,
insertPos: {
mode: 'GLOBAL',
exclude: ['/pages/errors/404/index'], // 排除不需要注册弹窗的页面
},
}),
useUni(),
]
//...
}
})
- main.ts 注册use
import { createSSRApp } from 'vue';
import App from './App.vue';
import LoginPopup 'src/components/login-popup/login-popup.vue';
import { showLoginPopup} from './utils/index'
export const createApp = () => {
const app = createSSRApp(App);
app.component('LoginPopup', LoginPopup); // 需与 pages.json 的 config 别名匹配
// 全局可通过this.$showLoginPopup直接调用打开弹窗
app.config.globalProperties.$showLoginPopup = showLoginPopup
return { app };
};
3.loginPopup.ts与页面解耦的登录弹窗逻辑
本模块提供与页面解耦的登录授权能力:业务侧只调用 showLoginPopup / closeLoginPopup 等函数,实际 UI 由各页面注入的 login-popup 组件承载。
前置条件
| 条件 | 说明 |
|---|---|
| Pinia 已就绪 | showLoginPopup 内使用 useUserStore(),需在应用 app.use(store) 之后调用。 |
| 当前页已挂载弹窗 | 通过 vite-inset-loader 全局注入或手写 <login-popup />,组件在 onMounted 时 registerLoginPopup。 |
路由与 getCurrentPagePath 一致 |
控件按 getCurrentPages() 栈顶页的 route(getCurrentPagePath())匹配实例,必须在当前正在展示的页面上存在弹窗组件,showLoginPopup 才能 open。 |
导入方式
import {
showLoginPopup,
closeLoginPopup,
resolveLoginPopupSuccess,
registerLoginPopup,
unregisterLoginPopup,
supersedePending,
} from '@/utils/modals/loginPopup'
// 或
import { showLoginPopup, closeLoginPopup } from '@/utils/modals'
全局属性:main.ts 中已将 showLoginPopup 挂到 app.config.globalProperties.$showLoginPopup。
API 说明
showLoginPopup(options?) → Promise<boolean>
在未登录时打开当前页对应的登录弹窗,并返回一个 Promise,在以下时机结束:
| 返回值 | 含义 |
|---|---|
true |
已登录(调用前就有 token):已执行 onSuccess;或用户在弹窗内登录成功,resolveLoginPopupSuccess 已把本次 Promise resolve(true)。 |
false |
当前页未注册弹窗、Pinia 未就绪、用户关闭弹窗/取消流程、登录失败、或又一次 showLoginPopup 顶替了上一次等待(上一次会得到 false)。 |
参数 ShowLoginPopupOptions
onSuccess?: () => void | Promise<void>- 已登录分支:在返回
true前先执行。 - 弹窗登录成功分支:在
resolveLoginPopupSuccess内执行,执行完后再resolve(true)(此时组件会再close弹窗)。
- 已登录分支:在返回
典型用法
const ok = await showLoginPopup({
async onSuccess() {
// token 已就绪后的逻辑,如刷新列表
},
})
if (ok) {
// 用户已登录(含本次刚登录)
}
路由守卫注意:若在 beforeEach 里 await showLoginPopup(),无论成功失败都要 next() / next(false),否则会卡住后续导航。见 src/router/guards/permission/index.ts。
closeLoginPopup() → void
- 调用
supersedePending():若存在未完成的showLoginPopupPromise,立即resolve(false)(视为取消)。 - 再对当前路由对应的已注册控件调用
close(),关闭uv-popup。
适用于:Tab onHide、切换页面前主动收起弹窗、业务上「取消登录」等。
resolveLoginPopupSuccess() → Promise<void>
仅应由 login-popup 在登录接口成功后调用,业务代码不要常规调用。
内部顺序:取出当前 pending → 清空 pending → await onSuccess?.() → resolve(true)。
supersedePending() → void
仅结束「等待中的 showLoginPopup Promise」为 false,不关闭弹窗 UI。
login-popup 在 uv-popup @change 收起(show === false)时调用,使用户点关闭/点遮罩时也能结束 await showLoginPopup,与 closeLoginPopup 中的行为一致(避免只关 UI 不结束 Promise)。
registerLoginPopup(controls) / unregisterLoginPopup(pagePath)
供 login-popup(或自定义等价组件)使用。
-
registerLoginPopup({ pagePath, open, close })pagePath:与getCurrentPagePath()一致的路由字符串(无前导/或与微信route字段一致,与项目工具保持一致即可)。- 同一
pagePath再次注册会先移除旧项再 push,避免重复注册。
-
unregisterLoginPopup(pagePath)
从内部列表移除该路径。默认注入的组件若需在卸载时与表严格同步,可在onUnmounted中传入注册时的pagePath调用(当前组件实现以closeLoginPopup为主,可按需补充)。
内部行为摘要
- 多页多实例:每个带
<login-popup />的页面注册一组open/close,按pagePath查找当前页对应控件,不是全局单例 ref。 - 单次等待:模块内最多一个
pending;新的showLoginPopup会先supersedePending(),上一次 Promise 得到false。 - 登录成功链路:
handleLogin→userStore.login→resolveLoginPopupSuccess()→popup.close()。
代码
import { getActivePinia } from 'pinia'
import { getCurrentPagePath } from '../index'
import { useUserStore } from '@/store/user'
export interface ShowLoginPopupOptions {
/** 登录成功(token 已写入)后执行,在关闭弹窗前调用 */
onSuccess?: () => void | Promise<void>
}
interface LoginPending {
onSuccess?: ShowLoginPopupOptions['onSuccess']
resolve: (ok: boolean) => void
}
let pending: LoginPending | null = null
interface LoginPopupControls {
pagePath: string
open: () => void
close: () => void
}
/**
* 多页面通过 vite-inset 各注入一份 login-popup 时,会多次 register。
* 若仅用单个引用 + unregister 置 null:从子页 navigateBack 后,上一页仍挂载但不会再次 onMounted,会报「登录弹窗未注册」。
* 用栈:后进先出,回退时 pop 回到上一页实例的 controls。
*/
const controlsStack: LoginPopupControls[] = []
function getActiveControls(): LoginPopupControls | null {
console.log('getActiveControls', controlsStack)
const pagePath = getCurrentPagePath()
const index = controlsStack.findIndex(c => c.pagePath === pagePath)
return index !== -1 ? controlsStack[index] : null
}
/**
* 由 `login-popup` 在挂载时注册,卸载时注销。 如果当前页面已注册,则替换掉之前的注册。
*/
export function registerLoginPopup(c: LoginPopupControls) {
const index = controlsStack.findIndex(item => item.pagePath === c.pagePath)
if (index !== -1) {
controlsStack.splice(index, 1)
}
controlsStack.push(c)
}
export function unregisterLoginPopup(pagePath: string) {
const index = controlsStack.findIndex(c => c.pagePath === pagePath)
if (index !== -1) {
controlsStack.splice(index, 1)
}
}
export function supersedePending() {
console.log('supersedePending执行', pending)
if (pending) {
pending.resolve(false)
pending = null
}
}
/**
* 打开全局登录弹窗。已登录时不会打开弹窗,直接执行 `onSuccess` 并返回 `true`。
* @returns 登录成功为 `true`;用户拒绝授权、登录接口失败、弹窗未挂载、或被新一次 `showLoginPopup` 顶替时为 `false`
*/
export async function showLoginPopup(options?: ShowLoginPopupOptions): Promise<boolean> {
if (!getActivePinia()) {
console.warn('[showLoginPopup] Pinia 尚未就绪,请稍后再试')
return false
}
const userStore = useUserStore()
if (userStore.token) {
try {
await options?.onSuccess?.()
}
catch (e) {
console.error('[showLoginPopup] onSuccess error', e)
}
return true
}
return new Promise((resolve) => {
const active = getActiveControls()
if (!active) {
console.warn('[showLoginPopup] 登录弹窗未注册:请确认页面已注入 <login-popup /> 或 App 根已挂载')
resolve(false)
return
}
supersedePending()
pending = {
onSuccess: options?.onSuccess,
resolve,
}
active.open()
})
}
/**
* 登录成功:执行回调、resolve(true)、清空 pending;由组件再调用 `close`。
*/
export async function resolveLoginPopupSuccess(): Promise<void> {
const p = pending
pending = null
if (!p)
return
try {
await p.onSuccess?.()
}
catch (e) {
console.error('[showLoginPopup] onSuccess error', e)
}
p.resolve(true)
}
/**
* 主动关闭弹窗并视为取消(resolve false):先 `supersedePending()`,再对当前页已注册控件执行 `close()`。
*/
export function closeLoginPopup() {
console.log('closeLoginPopup执行', pending)
supersedePending()
const active = getActiveControls()
active?.close()
}
更多推荐
所有评论(0)