项目框架: vite+uni-app+ts
ui: uv-ui

底部弹出的手机号授权登录弹窗,基于 @climblee/uv-uiuv-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来进行全局组件注入。

  1. 安装vite-inset-loader
pnpm add vite-inset-loader -D
# 或
npm i vite-inset-loader -D
# 或
yarn add vite-inset-loader -D
  1. 在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(),
		]
    //...
    }
})
  1. 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 />,组件在 onMountedregisterLoginPopup
路由与 getCurrentPagePath 一致 控件按 getCurrentPages() 栈顶页的 routegetCurrentPagePath())匹配实例,必须在当前正在展示的页面上存在弹窗组件,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) {
  // 用户已登录(含本次刚登录)
}

路由守卫注意:若在 beforeEachawait showLoginPopup(),无论成功失败都要 next() / next(false),否则会卡住后续导航。见 src/router/guards/permission/index.ts



closeLoginPopup()void

  • 调用 supersedePending():若存在未完成的 showLoginPopup Promise,立即 resolve(false)(视为取消)。
  • 再对当前路由对应的已注册控件调用 close(),关闭 uv-popup

适用于:Tab onHide、切换页面前主动收起弹窗、业务上「取消登录」等。


resolveLoginPopupSuccess()Promise<void>

仅应由 login-popup 在登录接口成功后调用,业务代码不要常规调用。

内部顺序:取出当前 pending → 清空 pendingawait onSuccess?.()resolve(true)


supersedePending()void

仅结束「等待中的 showLoginPopup Promise」为 false关闭弹窗 UI。

login-popupuv-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 为主,可按需补充)。


内部行为摘要

  1. 多页多实例:每个带 <login-popup /> 的页面注册一组 open/close,按 pagePath 查找当前页对应控件,不是全局单例 ref。
  2. 单次等待:模块内最多一个 pending;新的 showLoginPopup 会先 supersedePending(),上一次 Promise 得到 false
  3. 登录成功链路handleLoginuserStore.loginresolveLoginPopupSuccess()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()
}

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐