vue-app-sdk V3 文档

105 阅读18分钟

升级概要

  • 核心功能优化
  • 插件体系重构
  • 插件功能增强
  • 类型系统优化
  • 文档完善

文档目录

Animation

功能清单

  • 动态切换路由动画名

API

配置项

interface AnimationOptions {
  /**
   * 路由前进动画名称
   * @default 'forward'
   */
  forwardName?: MaybeRefOrGetter<string>
  /**
   * 路由后退动画名称
   * @default 'backward'
   */
  backwardName?: MaybeRefOrGetter<string>
}

实例

interface Animation {
  /**
   * 插件 ID
   */
  id: PluginID<Animation>

  /**
   * 配置项
   */
  options: AnimationOptions

  /**
   * 是否在路由导航后自动延迟启用动画
   */
  readonly isAutoEnabled: boolean

  /**
   * 动画启用状态
   * @default true
   */
  readonly isEnabled: boolean

  /**
   * 路由导航时的动画名称,禁用动画时将返回 `undefined`
   */
  readonly name: string | undefined

  /**
   * 前进动画名称
   */
  readonly forwardName: string

  /**
   * 后退动画名称
   */
  readonly backwardName: string

  /**
   * 设置是否在路由导航后自动延迟启用动画状态
   * @param value 状态值
   */
  setAutoEnabled: (value: boolean) => void

  /**
   * 设置动画启用状态
   * @param value 状态值
   */
  setEnabled: (value: boolean) => void

  /**
   * 启用动画
   */
  enable: () => void

  /**
   * 禁用动画
   *
   * ***注意:禁用时需设置 {@link https://cn.vuejs.org/api/built-in-components.html#transition Transition} 组件的 `css` 属性为 `false`,否则会影响切换效果!***
   */
  disable: () => void

  /**
   * 插件安装
   */
  install: (sdk: AppSDKInternalInstance) => Uninstall
}

初始化

// sdk.ts
import { createAnimation, createAppSDK } from 'vue-app-sdk'

export const sdk = createAppSDK()

sdk.use(
  createAnimation({
    // 设置前进后退动画类名
    forwardName: 'forward',
    backwardName: 'backward',
  })
)

使用方式

在调用 sdk.cleanup() 时自动还原插件状态。

<!-- App.vue -->
<script setup lang="ts">
import { useAppSDK, ANIMATION_ID } from 'vue-app-sdk'

const animation = useAppSDK().getPlugin(ANIMATION_ID)!

const router = useRouter()
function replacePage() {
  // 禁用导航动画
  animation.disable()
  router.replace('/a')
}

function pushPage() {
  // 前进动画
  router.push('/b')
}

function backPage() {
  // 后退动画
  router.back()
}
</script>

<template>
  <RouterView v-slot="{ Component: routerComp }">
    <!-- 禁用导航动画时同步禁用 Transition.css -->
    <Transition :name="animation.name" :css="animation.isEnabled">
      <Component :is="routerComp" />
    </Transition>
  </RouterView>
</template>

<style scoped>
/* 前进动画 */
.forward-active {
  /* ... */
}

/* 后退动画 */
.backward-active {
  /* ... */
}
</style>

Auth

功能清单

  • 动态授权编码列表
  • 指令式鉴权
  • 函数式鉴权

API

配置项

interface AuthOptions {
  /**
   * 授权指令
   * @default 'auth'
   */
  directiveName?: string
}

实例

interface Auth {
  /**
   * 插件 ID
   */
  id: PluginID<Auth>

  /**
   * 配置项
   */
  options: AuthOptions

  /**
   * 授权指令
   */
  directive: Directive<Element, MaybeArray<string>>

  /**
   * 授权的编码列表,'*' 代表所有权限
   * @default []
   */
  readonly list: string[] | '*'

  /**
   * 授权
   * @param list 编码列表
   *
   * @example
   * ```ts
   * // 设置用户权限编码列表
   * fetchUser().then((user) => auth.empower(user.permissions))
   *
   * function fetchUser() {
   *   return Promise.resolve({ id: 1, name: 'admin', permissions: ['list:add', 'list:edit'] })
   * }
   * ```
   */
  empower: (list: string[] | '*') => void

  /**
   * 鉴权
   * @param codes 编码列表
   * @param op 操作符,默认 'or'
   *
   * @example
   * ```ts
   * // 单功能鉴权
   * auth.verify('list:add')
   *
   * // 多功能鉴权,满足单一功能编码
   * auth.verify(['list:add', 'list:edit'], 'or')
   *
   * // 多功能鉴权,需同时满足所有功能编码
   * auth.verify(['list:add', 'list:edit'], 'and')
   * ```
   */
  verify: (codes: MaybeArray<string>, op?: 'and' | 'or') => boolean

  /**
   * 插件安装
   */
  install: (sdk: AppSDKInternalInstance) => void
}

初始化

// sdk.ts
import { createAppSDK, createAuth } from 'vue-app-sdk'

export const sdk = createAppSDK()

sdk.use(
  createAuth({
    // 安装时会自动注册鉴权指令,可通过传入 `directiveName` 更改指令名
    directiveName: 'auth' // 使用 `v-auth`
  })
)

使用方式

在调用 sdk.cleanup() 时自动清理授权的编码列表。

动态授权

import { AUTH_ID, useAppSDK } from 'vue-app-sdk'

const auth = useAppSDK().getPlugin(AUTH_ID)

getUserInfo().then(({ permission }) => {
  // 设置用户权限编码列表
  auth.empower(permission)

  // 授予所有权限
  // auth.empower('*')
})

function getUserInfo() {
  return Promise.resolve({
    id: 1,
    name: 'admin',
    permission: ['list:add', 'list:edit']
  })
}

指令式鉴权

<!-- Xxx.vue -->
<template>
  <div>
    <button v-auth="'list.add'">新增</button>
    <button v-auth="'list.edit'">修改</button>
    <button v-auth:and="['list.add', 'list.edit']">删除</button>
  </div>
</template>

函数式鉴权

<!-- Xxx.vue -->
<script setup>
import { useAppSDK, AUTH_ID } from 'vue-app-sdk'

const auth = useAppSDK().getPlugin(AUTH_ID)!

function handleDelete() {
  if (!auth.verify(['list.add', 'list.edit'], 'and')) {
    return alert('没有授权新增和编辑功能')
  }

  alert('删除成功')
}
</script>

Core

vue-app-sdk 核心功能。

功能清单

  • 插件中心管理
  • 全局事件中心管理
  • 扩展路由器功能

API

配置项

interface AppSDKOptions {
  /**
   * 内置扩展路由器插件配置
   */
  router?: RouterOptions
}

Hookable

Hookable 传送门

实例

interface AppSDK extends Hookable {
  /**
   * 配置项
   */
  options: AppSDKOptions

  /**
   * 是否已初始化
   */
  isInitialed: boolean

  /**
   * Vue 实例
   */
  app?: App

  /**
   * VueRouter 实例
   */
  router?: Router

  /**
   * 插件列表
   */
  readonly plugins: Plugin[]

  /**
   * 注册插件
   * @param plugin 插件
   *
   * @example
   * ```ts
   * sdk.use(Plugin1).use(Plugin2)
   * ```
   */
  use: (plugin: Plugin) => AppSDK

  /**
   * 根据插件 ID 获取插件实例
   * @param id 插件 ID
   *
   * @example
   * ```ts
   * const Plugin1 = sdk.getPlugin(Plugin1ID)!
   * Plugin1.run()
   * ```
   */
  getPlugin: (id: string | PluginID) => Plugin | undefined

  /**
   * 根据插件 ID 列表获取插件实例列表
   * @param ids 插件 ID 列表
   *
   * @example
   * ```ts
   * const [Plugin1, Plugin2] = sdk.getPlugins(Plugin1ID, Plugin2ID)
   *
   * Plugin1?.run()
   * Plugin2?.test()
   * ```
   */
  getPlugins: (...ids: (string | PluginID)[]) => (Plugin | undefined)[]

  /**
   * 集中清理插件缓存,不会影响插件正常运行
   */
  cleanup: () => void

  /**
   * 手动初始化 AppSDK
   * @param app Vue 应用实例
   */
  init: (app: App) => void

  /**
   * 手动销毁 AppSDK
   * 会依次执行:清理缓存 => 卸载插件 => 移除全部事件监听 => 释放内部数据
   */
  destroy: () => void

  /**
   * 安装到 Vue 实例
   * @param app Vue 应用实例
   */
  install: (app: App) => void

  /**
   * 同步触发指定事件回调列表
   * @param name 指定事件
   * @param args 回调参数
   */
  callHookSync: (name: string, ...args: any[]) => void

  /**
   * 注册事件并在 `onScopeDispose()` 时自动移除监听
   * @param name 事件名
   * @param callback 回调句柄
   */
  hookScope: (name: string, callback: Function) => void
}

初始化

// sdk.ts
import { createAppSDK } from 'vue-app-sdk'

// 创建 SDK 实例
const sdk = createAppSDK({
  // opts...
})
// main.ts
// ...

const app = createApp(App)

// 必须先注册路由器
app.use(router)
// 注册 SDK
app.use(sdk)

app.mount('#app')

扩展 SDK

// sdk.ts
import { createAnimation, createAppSDK } from 'vue-app-sdk'

// 创建 SDK 实例
export const sdk = createAppSDK({
  // opts...
})

// 注册转场动画插件
sdk.use(
  createAnimation({
    forwardName: 'forward',
    backwardName: 'backward',
  })
)

使用方式

获取 SDK 实例

  • useAppSDK

注意:仅支持在 setup 阶段获取。

<script setup lang="ts">
import { useAppSDK } from 'vue-app-sdk'

const sdk = useAppSDK()
</script>
  • app.config.globalProperties
<script setup lang="ts">
import { getCurrentInstance } from 'vue'

const sdk = getCurrentInstance()!.proxy!.$appSDK
</script>

<template>
  <!-- 模板内部可直接通过 $appSDK 获取 -->
  <div>{{ $appSDK }}</div>
</template>
  • src/sdk.ts
<script setup lang="ts">
import { sdk } from '@/sdk'
</script>

事件中心

// types/sdk.d.ts
declare module 'vue-app-sdk' {
  interface AppSDKHooks {
    // 自定义全局事件类型
    'something-event': (value1: number, value2: string) => void
  }
}
// 注册事件监听
const removeHook = SDK.hook('something-event', (value1, value2) => {
  console.log(value1, value2)
  // do something...
})

// 触发事件
sdk.callHook('something-event', 1, '2')

// 不需要时可移除监听回调
removeHook()

// 移除指定事件全部回调
sdk.removeHooks('something-event')

路由扩展功能

查看详情

集中清理

AppSDK 内置了 sdk:cleanup 事件,通过调用 sdk.cleanup() 来通知插件进行缓存数据的清理,清理的数据不得影响插件和应用的正常运行,常用于退出登录时调用。

插件开发

// myPlugin.ts
import type { Plugin, PluginID } from 'vue-app-sdk'

// 创建插件 ID,用于在 `sdk.getPlugin(id)` 时获取插件实例
// 推荐使用 `PluginID<TPlugin>` 定义插件 ID,可以在 `getPlugin(id)` 时被智能感知
export const MY_PLUGIN_ID: PluginID<MyPlugin> = Symbol('MyPlugin')

// 使用类来实现 `Plugin` 接口
// 类的方法推荐使用箭头函数编写,避免调用时丢失 `this`
export class MyPlugin implements Plugin {
  // 设置插件 ID
  id = MY_PLUGIN_ID

  // 插件被安装时执行
  install = () => {
    // do something...

    // 插件被卸载时执行,优先级高于 `uninstall()`
    // return () => {}
  }

  // 插件卸载时执行
  uninstall = () => {
    // dom something...
  }
}

KeepAlive

功能清单

  • 自动收集、释放缓存
  • 定时清理无效缓存
  • 支持通过路由对象进行缓存

API

配置项

interface KeepAliveOptions {
  /**
   * 是否自动在路由前进时收集,后退时清理缓存,可用于模拟移动端缓存处理
   * @default true
   */
  autoCollectAndClean?: MaybeRefOrGetter<boolean>

  /**
   * 添加路由缓存前执行,返回假值将阻止添加
   */
  beforeRouteAdd?: (route: LoadableRoute) => Awaitable<void | boolean>

  /**
   * 删除路由缓存前执行,返回假值将阻止删除
   */
  beforeRouteRemove?: (route: LoadableRoute) => Awaitable<void | boolean>

  /**
   * 缓存保鲜时间,设置大于 `0` 后将开启定时器清理过期缓存
   */
  staleTime?: number
}

实例

interface KeepAlive {
  /**
   * 插件 ID
   */
  id: PluginID<KeepAlive>

  /**
   * 配置项
   */
  options: KeepAliveOptions

  /**
   * 缓存的组件名称列表
   */
  readonly values: string[]

  /**
   * 是否自动模式
   * @default true
   */
  readonly isAuto: boolean

  /**
   * 启动缓存保鲜定时器
   */
  startStaleTimer: () => void

  /**
   * 停止缓存保鲜定时器
   */
  stopStaleTimer: () => void

  /**
   * 清理过期缓存,设置 `options.staleTime > 0` 后有效
   */
  clearExpired: () => void

  /**
   * 切换自动模式
   * @param value 状态值
   */
  toggleAuto: (value?: boolean) => boolean

  /**
   * 启用自动模式
   */
  enableAuto: () => boolean

  /**
   * 禁用自动模式
   */
  disableAuto: () => boolean

  /**
   * 添加路由缓存
   * @param name 需要缓存的路由组件名称
   */
  add: {
    (name: string): void
    (route: LoadableRoute): Promise<void>
  }

  /**
   * 移除路由缓存
   * @param name 需要移除的路由组件名称
   */
  remove: {
    (name: string): void
    (route: LoadableRoute): Promise<void>
  }

  /**
   * 刷新路由缓存
   * @param name 需要刷新的路由组件名称
   */
  refresh: {
    (name: string): void
    (route: LoadableRoute): Promise<void>
  }

  /**
   * 清空缓存列表
   */
  clear: () => void

  /**
   * 设置缓存的名称列表,会覆盖旧列表
   * @param values 缓存名称列表
   */
  set: (values: string[]) => void

  /**
   * 插件安装
   */
  install: (sdk: AppSDKInternalInstance) => Uninstall
}

初始化

// sdk.ts
import { createAppSDK, createKeepAlive } from 'vue-app-sdk'

export const sdk = createAppSDK()

sdk.use(
  createKeepAlive({
    // 自动在路由前进后退时收集和清理缓存
    autoCollectAndClean: true,
    // 根据路由添加缓存前回调,返回假值将阻止添加
    beforeRouteAdd: (route) => route.meta.isKeepAlive,
  })
)

使用方式

在调用 sdk.cleanup() 时自动清理缓存列表。

基础用法

<!-- App.vue -->
<script setup>
import { useAppSDK, KEEP_ALIVE_ID } from 'vue-app-sdk'

const keepAlive = useAppSDK().getPlugin(KEEP_ALIVE_ID)!
</script>

<template>
  <div class="view-container">
    <RouterView v-slot="{ Component, route }">
      <KeepAlive :include="keepAlive.values">
        <Component
          :is="Component"
          :key="route.fullPath"
        />
      </KeepAlive>
    </RouterView>
  </div>
</template>

刷新当前页

<script setup lang="ts">
// ...

const showView = ref(true)
const route = useRoute()
function refreshCurrentPage() {
  // 刷新路由页面缓存
  const promise = keepAlive.refresh(route)
  // 隐藏视图
  showView.value = false
  // 缓存刷新结束后显示视图
  promise
    // 避免视图更新时机过早
    .then(() => nextTick())
    .then(() => {
      showView.value = true
    })
}
</script>

<template>
  <div class="view-container">
    <RouterView v-slot="{ Component, route }">
      <KeepAlive :include="keepAlive.values">
        <Component
          v-if="showView"
          :is="Component"
          :key="route.fullPath"
        />
      </KeepAlive>
    </RouterView>
  </div>
</template>

定时清理

常用于列表页,这类页面缓存需要被定期清理,以保证用户能及时获取到正确的列表数据。

// sdk.ts
// ...

sdk.use(
  createKeepAlive({
    // ...

    // 缓存保鲜时间设为 10 分钟,当页面缓存过期时将被清理
    staleTime: 10 * 60e3
  })
)

Page

页面元数据管理器,抹平前后端开发时页面数据处理不一致问题,以标准化的数据结构来满足不同的应用。

功能清单

  • 支持异构数据转换为标准化页面元数据
  • 支持路由视图组件自动追加 name
  • 支持静态页面权限访问
  • 支持重置所有动态添加的路由
  • 提供常用辅助函数

API

配置项

interface PageOptions {
  /**
   * 是否为移动端应用,设为 `true` 时在调用 `handleMenuClick` 时将禁用动画插件(若存在)的导航动画并使用 `router.replace` 跳转
   */
  isMobile?: MaybeRefOrGetter<boolean>
  /**
   * 跳转外链时若存在已打开的外链窗口是否重复使用进行跳转
   * @default false
   */
  linkSingleWindow?: boolean
  /**
   * 调用 `handleMenuClick` 时是否允许跳转外链,返回假值时走正常页面跳转,常用于页面内部自行处理外链渲染方式
   * @default (menu) => menu.link
   */
  allowLink?: (menu: PageMetadata) => boolean
  /**
   * 根据 `page.file` 获取真实组件
   * @example
   * ```ts
   * const page = createPage({ resolveComponent: (file) => () => import(`src/${file}.vue`) })
   *
   * const route = page.pageToRoute({ file: 'home/index' })
   * route.component // () => import('src/home/index.vue')
   */
  resolveComponent: (file: string, page: PageMetadata) => MaybeFn<Awaitable<RouteComponent>, []>
  /**
   * 校准不符合 `PageMetadata` 的元数据
   * @param data 不符合的元数据
   * @returns 符合的元数据
   *
   * @example
   * ```ts
   * // ❌ 不符合 PageMetadata 的元数据
   * { id: 1, routeName: 'home', routePath: '/home', sort: 0, ... }
   *
   * // 校准后 ↓
   *
   * // ✔️ 符合 PageMetadata 的元数据
   * { id: 1, name: 'home', path: '/home', index: 0, ... }
   * ```
   */
  calibrateMetadata?: (data: any) => PageMetadata
}

实例

interface Page {
  /**
   * 插件 ID
   */
  id: PluginID<Page>

  /**
   * 配置项
   */
  options: PageOptions

  /**
   * 校准页面元数据
   */
  readonly calibrateMetadata: (data: any) => PageMetadata

  /**
   * 是否允许跳转外链
   */
  readonly allowLink: (menu: PageMetadata) => boolean

  /**
   * 重置路由列表为初始创建路由器时传入的列表
   */
  resetRoutes: () => void

  /**
   * 获取路由上的页面元数据(浅拷贝)
   * @param data `route` 或 `route.meta`
   */
  getPageMetadata: (data: Pick<RouteRecordNormalized, 'meta'> | RouteMeta) => Partial<PageMetadata>

  /**
   * 将树形元数据列表扁平化为符合 `PageMetadata` 的页面元数据列表
   * @param data 树形元数据列表
   * @param options 配置项
   * @returns 扁平化的页面元数据列表
   */
  toFlattenPages: <T extends {}, Calibrate extends boolean = false>(data: T[], options?: Pick<ToTreeArrayOptions<never, never>, 'childrenKey'> & {
    /**
     * 是否克隆源数据,避免转换时影响到源数据
     * @default false
     */
    clone?: boolean | undefined
    /**
     * 是否校准元数据,数据格式不符合 `PageMetadata` 时可以设为 `true`
     * @default false
     */
    calibrate?: Calibrate | undefined
    /**
     * 是否补全路径,设为 `true` 后会对 `path` 进行拼接补全,可以在 `addRoute()` 时不受父子级限制
     * @default false
     */
    completePath?: boolean | undefined
  }) => Calibrate extends true ? PageMetadata[] : T[]

  /**
   * 将元数据列表转为符合 `PageMetaWithChildren` 的树形页面元数据列表
   * @param data 元数据列表
   * @param options 配置项
   * @param options.strict 严格模式,开启后将移除父子关联不存在的数据
   * @param options.clone 是否克隆源数据,避免转换时影响到源数据
   * @param options.calibrate 是否校准元数据,数据格式不符合 `PageMetadata` 时可以设为 `true`
   * @param options.completePath 是否补全路径,设为 `true` 后会对 `path` 进行拼接补全,可以在 `addRoute()` 时不受父子级限制
   * @returns 树形页面元数据列表
   */
  toTreePages: <T extends {}, Calibrate extends boolean = false>(data: T[], options?: {
    /**
     * 严格模式,开启后将移除父子关联不存在的数据
     */
    strict?: boolean | undefined
    /**
     * 是否克隆源数据,避免转换时影响到源数据
     * @default false
     */
    clone?: boolean | undefined
    /**
     * 是否校准元数据,数据格式不符合 `PageMetadata` 时可以设为 `true`
     * @default false
     */
    calibrate?: Calibrate | undefined
    /**
     * 是否补全路径,设为 `true` 后会对 `path` 进行拼接补全,可以在 `addRoute()` 时不受父子级限制
     * @default false
     */
    completePath?: boolean | undefined
  }) => Calibrate extends true ? PageMetadataWithChildren[] : WithChildren<T, 'children', false>[]

  /**
   * 父子关联页面元数据列表转为树形菜单列表,返回深克隆数据,与源数据不共用内存引用
   * @param pages 页面元数据列表
   * @param strict 严格模式
   * @returns 树形菜单元数据列表
   */
  toMenus: (pages: PageMetadata[], strict: boolean) => PageMetadataWithChildren[]

  /**
   * 指定角色列表是否可以访问页面
   * @param page 页面元数据
   * @param roleList 角色列表
   * @param strict 严格模式
   * @returns 是否可以访问页面
   */
  canVisitPage: (page: PageMetadata, roleList: RoleList, strict: boolean) => boolean

  /**
   * 过滤指定角色可以访问的页面元数据列表
   * @param pages 页面元数据列表
   * @param roleList 角色列表
   * @param strict 严格模式
   * @returns 可以访问的页面元数据列表
   */
  filterVisitPages: (pages: PageMetadata[], roleList: RoleList, strict: boolean) => PageMetadata[]

  /**
   * 创建不同场景下的页面元数据状态
   * @param pages 页面元数据列表
   * @param options 配置项
   */
  createStates: <T extends {}, ActiveMenu = string>(
    pages: MaybeRefOrGetter<T[]>,
    options: Pick<ToTreeArrayOptions<never, never>, 'childrenKey'> & {
    /**
     * `pages` 格式
     */
      format: MaybeRefOrGetter<'list' | 'tree'>
      /**
       * 严格模式,开启后扁平化列表转为树形列表时将移除父子关联不存在的数据
       */
      strict?: MaybeRefOrGetter<boolean> | undefined
      /**
       * 角色列表,用于过滤页面
       * @default []
       */
      roleList?: MaybeRefOrGetter<RoleList> | undefined
      /**
       * 严格访问模式,开启时若指定角色列表或页面角色列表为空时则不能访问页面
       * @default true
       */
      strictVisit?: MaybeRefOrGetter<boolean> | undefined
      /**
       * 角色过滤页面时是否子级优先
       * - `true`: 子级存在权限时父级必定存在
       * - `false`: 父级无权限时子级必定不存在
       * @default true
       */
      childrenFirst?: MaybeRefOrGetter<boolean> | undefined
      /**
       * 是否补全路径,设为 `true` 后会对 `path` 进行拼接补全,可以在 `addRoute()` 时不受父子级限制
       * @default true
       */
      completePath?: MaybeRefOrGetter<boolean> | undefined
      /**
       * 根据当前路由获取激活菜单标识
       * @default
       * ```ts
       * (route) => this.getPageMetadata(route).activeMenu || this.getPageMetadata(route).name || ''
       * ```
       */
      resolveActiveMenu?: ((route: RouteLocationNormalizedLoaded, states: {
        flattenPages: PageMetadata[]
        treePages: PageMetadataWithChildren[]
        visitablePages: PageMetadata[]
        visitableTreePages: PageMetadataWithChildren[]
        visitableMenus: PageMetadataWithChildren[]
        visitablePageMap: Record<PageMetadata['id'], PageMetadata>
        visitableTreeLinkMap: Record<PageMetadata['id'], PageMetadataWithChildren[]>
      }) => ActiveMenu) | undefined
    }) => {
    /**
     * 扁平化的页面元数据列表
     */
    flattenPages: import('vue').ShallowRef<PageMetadata[]>
    /**
     * 树形页面元数据列表
     */
    treePages: import('vue').ShallowRef<PageMetadataWithChildren[]>
    /**
     * 可访问的树形页面元数据列表
     */
    visitableTreePages: import('vue').ShallowRef<PageMetadataWithChildren[]>
    /**
     * 可访问的页面元数据列表
     */
    visitablePages: import('vue').ShallowRef<PageMetadata[]>
    /**
     * 可访问的页面元数据映射
     */
    visitablePageMap: import('vue').ShallowRef<Record<PageMetadata['id'], PageMetadata>>
    /**
     * 可访问的树形页面元数据链路映射
     */
    visitableTreeLinkMap: import('vue').ShallowRef<Record<PageMetadata['id'], PageMetadataWithChildren[]>>
    /**
     * 可访问的菜单页面元数据列表
     */
    visitableMenus: import('vue').ShallowRef<PageMetadataWithChildren[]>
    /**
     * 激活的菜单页面标识
     */
    activeMenu: import('vue').ComputedRef<ActiveMenu>
  }

  /**
   * 页面元数据转为路由配置
   * @param page 页面元数据
   * @param extraProps 路由额外参数
   * @param syncName 同步组件 `name` 为路由 `name`,可以更好的配合 `KeepAlive` 组件缓存
   * @returns 路由配置
   */
  pageToRoute: (
    page: PageMetadata,
    extraProps?: Omit<RouteRecordRaw, 'path' | 'name' | 'redirect' | 'component'>,
    syncName?: boolean | ((name: string, page: PageMetadata) => string)
  ) => RouteRecordRaw

  /**
   * 获取页面元数据设置的外链地址,设为假值时返回空字符串
   * @param page 页面元数据
   * @returns 外链地址
   */
  resolveLink: (page: PageMetadata) => string

  /**
   * 处理菜单点击,设置 `link` 时将会在客户端新标签页打开指定路由页或网站,
   * 在 `options.isMobile` 设为 `true` 时将禁用动画插件(若存在)的导航动画并使用 `router.replace` 跳转
   * @param menu 页面元数据
   * @param singleWindow 跳转外链时若存在已打开的外链窗口是否重复使用进行跳转
   */
  handleMenuClick: (menu: PageMetadata, singleWindow?: boolean | undefined) => void

  /**
   * 插件安装
   */
  install: (sdk: AppSDKInternalInstance) => () => void
}

初始化

// sdk.ts
import { createAppSDK, createPage } from 'vue-app-sdk'

// 导入视图模块
const modules = import.meta.glob([
  '@/views/**/*.vue',
  // 排除 components 下的 vue 文件
  '!**/components/**/*.vue',
])

export const sdk = createAppSDK()

sdk.use(
  createPage({
    // 根据 `file` 设置 `route.component`
    resolveComponent: (file) => modules[`./${file}.vue`],
    // 修正元数据结构,若为同构数据可忽略
    calibrateMetadata: (data) => data,
    // 仅大屏外链允许跳转至新标签页
    allowLink: (menu) => !!menu.link && !!menu.isFull,
  })
)

使用方式

推荐搭配 pinia 使用!

1. 创建静态页面元数据列表

若无需后端接口介入可自行创建前端静态元数据列表!

// assets/data/pages.ts
// 使用 unplugin-macros 时可以通过声明为编译宏减少运行时开销
// import { defineStaticPages } from 'vue-app-sdk' assert { type: 'macro' }

// 使用 `defineStaticPages` 可以为代码编辑器提供智能辅助提示,
// 并且该函数将自动补充父子页面关联关系(追加 id、parentId),用于统一前后端页面元数据格式
import { defineStaticPages } from 'vue-app-sdk'

export default defineStaticPages([
  // 子级菜单配置
  {
    // `route.path`
    path: '/home',

    // `route.name`,需保持唯一
    name: 'home',

    // 视图文件地址,搭配 `resolveComponent` 使用,若无时可不设置
    file: 'Home/index',

    // 页面标题
    title: '首页',
    // 国际化时可设置为对象形式,通过 translateText(title) 处理
    // title: { 'zh-cn': '首页', en: 'Home', },

    // `route.query`,支持 `JSON` 字符串
    routeQuery: {},
    // route.params,支持 `JSON` 字符串
    routeParams: {},

    // 需要挂载菜单时设为 true,可不设置
    isMenu: true,
    // PC 模式时用于判断页面是否需要保活,可不设置
    isKeepAlive: true,
    // PC 模式时用于固定到标签页上,可不设置
    isAffix: true,
    // PC 模式时在标签栏中保持唯一,可不设置
    isUniq: true,

    // 静态的允许访问的角色编码列表,不需要时参考以下两种方式:
    // 1. 设置为 '*',允许任意用户访问
    // 2. createStates([], { createStates: false }),之后可不设置 `roleList`
    roleList: '*',

    // 菜单图标
    icon: 'MenuIcon',

    // 子级页面列表
    children: [
      {
        name: 'home-detail',
        path: 'detail/:time',
        file: 'Home/Detail/index',
        title: '首页详情',
        isKeepAlive: true,
        roleList: '*',

        // 子级非菜单页面时需要激活的菜单 `name`
        activeMenu: 'home',
      },
    ],
  },

  // 链接菜单配置
  {
    name: 'link',
    path: '/link',
    title: '外部链接',
    isMenu: true,
    roleList: '*',

    // 自身作为父级菜单时没有页面文件,可设置激活时重定向至子级菜单 `name`
    redirect: 'bing',

    children: [
      {
        name: 'bing',
        path: 'bing',

        // 设置链接地址,存在时将跳转至目标页面
        link: '//www.bing.com/',

        // 未设置 `isFull` 且在 `createPage({ allowLink: ... })` 拦截后,
        // 可通过 `file` 或 `redirect` 进入指定视图组件,自行处理外链显示方式
        file: 'IFrame/index',

        title: 'Bing 内嵌',
        isMenu: true,
        roleList: '*',
      },
    ],
  },

  // 非布局菜单配置
  {
    path: '/data-screen',
    name: 'data-screen',
    file: 'DataScreen/index',
    title: '数据大屏',
    isMenu: true,

    // 一般大屏页面需脱离管理应用的基础布局组件,可设置 `isFull` 进行标识
    isFull: true,
    // 设置自身页面作为外链跳转
    link: true,

    roleList: '*',
  },
])

2. 创建 user.ts 储存授权页面信息

// stores/user.ts
import { computed, ref } from 'vue'
import { PAGE_ID } from 'vue-app-sdk'
import { sdk } from '@/sdk'
import staticPages from '@/assets/data/pages'

const store = defineStore('auth', () => {
  // 源页面元数据列表
  const pages = ref([])
  // 模拟接口请求获取可访问的页面元数据列表
  const getVisitablePages = async () => {
    pages.value = await Promise.resolve(staticPages.slice(0))
  }

  const Page = sdk.getPlugin(PAGE_ID)!
  // 根据 pages 使用 createStates 快速创建不同场景下的页面元数据状态
  const {
    // 可访问的扁平化元数据列表
    visitablePages,
    // 可访问的树形元数据列表
    visitableTreePages,
    // 可访问的树形元数据链路映射
    visitableTreeLinkMap,
    // 可访问的树形菜单列表
    visitableMenus,
    // 当前激活的菜单
    activeMenu,
  } = Page.createStates(pages, {
    // 标明源数据格式,可选值:tree、list
    format: 'tree',

    // 子级列表属性名
    childrenKey: 'children',

    // 过滤权限时是否子级优先
    // - `true`: 子级存在权限时父级必定存在
    // - `false`: 父级无权限时子级必定不存在
    childrenFirst: true,

    // 补全 `path` 为绝对路径,可以在 `addRoute()` 时不受父子级限制
    completePath: true,

    // 当前登录角色权限列表,用来配合 `page.roleList` 过滤页面列表
    roleList: () => '*',

    // 根据当前路由获取激活菜单标识
    resolveActiveMenu: (route, { visitableTreeLinkMap }) => {
      const metadata = Page.getPageMetadata(route)
      const id = metadata.id
      const activeMenu = metadata.activeMenu
      const routeName = (route.name || '') as string

      // 存在 `activeMenu` 时直接返回
      if (activeMenu)
        return activeMenu

      // `id` 为空时返回路由名称
      if (id == null)
        return routeName

      // 基于可访问树链路映射查询最近的菜单节点并返回其路由名称
      const links = visitableTreeLinkMap[id]
      for (let i = links.length - 1; i >= 0; i--) {
        const node = links[i]
        if (node.isMenu)
          return node.name
      }

      // 未找到时返回路由名称
      return routeName
    }
  })

  // 手动指定默认页
  const DEFAULT_PAGE = ''
  /** 默认页面 name */
  const defaultPage = computed(() => {
    return DEFAULT_PAGE || visitableTreePages.value[0]?.name
  })

  // ...

  return {
    // 导出会用到的数据
    /** 可访问的页面列表 */
    visitablePages,
    /** 可访问的树形页面链路映射 */
    visitableTreeLinkMap,
    /** 可访问的菜单列表 */
    visitableMenus,
    /** 当前激活的菜单 */
    activeMenu,
    /** 默认页面标识 */
    defaultPage,

    /** 获取登录用户信息 */
    getUserInfo,
    /** 获取登录用户可访问的页面列表 */
    getVisitablePages,

    // ...
  }
})

export function useUserStore() {
  // https://pinia.web3doc.top/core-concepts/outside-component-usage.html
  return store(pinia)
}

3. 动态注册路由数据

// router/index.ts

// ...

export const router = createRouter({
  routes: [
    // 注册静态布局页
    {
      // 尽量起一个不会冲突的名字,由于子级 `path` 会被补全为绝对路径,这里不会显示在地址栏
      path: '/normalLayout',
      name: 'layout',
      component: () => import('@/layout/index.vue'),
      // 子级设置为空数组,后续动态挂载路由列表
      children: [],
    },
  ]
})

// 重置路由
export function resetRouter() {
  // 将会重置所有动态加载的路由
  sdk.getPlugin(PAGE_ID)!.resetRoutes()
}

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()

  // 没有可访问页面时请求数据并挂载动态路由
  if (!userStore.visitablePages.length) {
    await userStore.getVisitablePages()

    // 请求后仍然无可访问页面则提示并返回
    if (userStore.visitablePages.length === 0) {
      alert('当前账号无任何页面权限,请联系管理员!')
      return Promise.reject(new Error('No Permission!'))
    }

    // 注册可访问路由
    const page = sdk.getPlugin(PAGE_ID)!
    userStore.visitablePages.forEach((item) => {
      if (item.isFull) {
        // 注册全局页面
        router.addRoute(page.pageToRoute(item, { props: true }))
      }
      else {
        // 注册布局页面
        router.addRoute('layout', page.pageToRoute(item, { props: true }))
      }
    })

    // 替换目标路由
    return { ...to, replace: true }
  }

  // 重定向至默认页
  if (to.path === '/' || ['layout'].includes(to.name as string)) {
    return {
      name: userStore.defaultPage,
      query: to.query,
      params: to.params,
      replace: true,
    }
  }
})

4. 渲染菜单

这里使用 ElementPlus 进行开发示范,其他组件库同理。

  1. 创建递归子菜单组件
<!-- layout/components/ReMenuItem.vue -->
<script setup lang="ts">
import type { PageMetadataWithChildren } from 'vue-app-sdk'
import { useAppSDK, PAGE_ID } from 'vue-app-sdk'

defineProps<{
  // 由于 `ElementPlus` 的 `ElMenu` 组件会获取插槽虚拟节点列表,
  // 在水平时处理宽度溢出问题,所以这里仅接收单个菜单配置进行渲染
  menu: PageMetadataWithChildren
}>()

const page = useAppSDK().getPlugin(PAGE_ID)!

function handleMenuClick(menu: PageMetadataWithChildren) {
  // 直接使用提供的函数处理点击事件
  page.handleMenuClick(menu)
}
</script>

<template>
  <!-- 若存在子级列表时使用 `ElSubMenu` 渲染父级,之后递归渲染子级 -->
  <template v-if="menu.children">
    <ElSubMenu
      :index="menu.name"
      class="re-sub-menu"
    >
      <template #title>
        <div>{{ menu.title }}</div>
      </template>
      <ReMenuItem
        v-for="item in menu.children"
        :key="item.id"
        :menu="item"
      />
    </ElSubMenu>
  </template>
  <template v-else>
    <!-- 渲染子级菜单 -->
    <ElMenuItem
      class="re-menu-item"
      :index="menu.name"
      @click="handleMenuClick(menu)"
    >
      <template #title>
        <div>{{ menu.title }}</div>
      </template>
    </ElMenuItem>
  </template>
</template>
  1. 创建菜单组件
<!-- layout/components/Menu.vue -->
<script setup lang="ts">
import ReMenuItem from './ReMenuItem.vue'
import { useUserStore } from '@/stores/user'

const authStore = useUserStore()
</script>

<template>
  <ElMenu
    mode="vertical"
    :router="false"
    :default-active="userStore.activeMenu"
  >
    <!-- 遍历 menus 渲染递归子菜单组件 -->
    <ReMenuItem
      v-for="item in userStore.visitableMenus"
      :key="item.id"
      :menu="item"
    />
  </ElMenu>
</template>

5. 渲染面包屑导航

这里使用 ElementPlus 进行开发示范,其他组件库同理。

<!-- layout/components/Breadcrumb.vue -->
<script setup lang="ts">
import type { PageMetadata } from 'vue-app-sdk'
import { PAGE_ID, useAppSDK } from 'vue-app-sdk'
import { useUserStore } from '@/stores/user'

const route = useRoute()
const page = useAppSDK().getPlugin(PAGE_ID)!
const userStore = useUserStore()

const breadcrumbList = computed(() => {
  // 根据当前路由元数据的 id 获取授权页面树形节点链路
  return userStore.visitableTreeLinkMap[page.getPageMetadata(route).id!] || []
})

function onBreadcrumbClick(item: PageMetadata, index: number) {
  // 如果是末级节点则跳过点击
  if (index === breadcrumbList.value.length - 1) return
  // 直接使用 Page 提供的函数处理点击
  page.handleMenuClick(item)
}
</script>

<template>
  <ElBreadcrumb>
    <ElBreadcrumbItem
      v-for="(item, index) in breadcrumbList"
      :key="item.id"
    >
      <ElLink
        :underline="false"
        @click="onBreadcrumbClick(item, index)"
      >
        <ElText>{{ item.title }}</ElText>
      </ElLink>
    </ElBreadcrumbItem>
  </ElBreadcrumb>
</template>

适配后端开发

适配后端开发时只需将 user.ts 中的获取页面数据函数替换为真实接口,并在 createPage() 时定义 calibrateMetadata() 将接口数据转换为标准化的页面元数据即可。

国际化开发

一般都会采用 vue-i18n 作为国际化的工具,这里仅提供该方案的示例代码,逻辑较为简单,也可自行实现。

// i18n.ts
import { createTranslator } from 'vue-app-sdk'

export const i18n = createI18n({
  // ...
})

/**
 * 根据 i18n 实例创建本地翻译函数,用于处理动态国际化文本
 *
 * @example
 * ```ts
 * // locale.ts
 * export const translateText = createTranslator(i18n)
 *
 * // menu.ts
 * const menu = { title: '菜单' }
 * translateText(menu.title) // '菜单'
 *
 * const menu = { title: { 'zh-cn': '菜单', en: 'Menu' } }
 * translateText(menu.title) // i18n.global.locale 为 `zh-cn` 时为 '菜单',`en` 时为 'Menu'
 *
 * // alert.ts
 * alert(translateText({ 'zh-cn': '这是中文警告!', en: 'This is a warning in English!' }))
 *
 * // menu.vue
 * const menu = { title: { 'zh-cn': '菜单', en: 'Menu' } }
 * const menuTitle = computed(() => translateText(menu.title)) // 支持响应式动态变更
 * ```
 */
export const translateText = createTranslator(i18n)

以子菜单渲染为例:

<template>
  <!-- 若存在子级列表时使用 `ElSubMenu` 渲染父级,之后递归渲染子级 -->
  <template v-if="menu.children">
    <ElSubMenu
      :index="menu.name"
      class="re-sub-menu"
    >
      <template #title>
-        <div>{{ menu.title }}</div>
+        <div>{{ translateText(menu.title) }}</div>
      </template>
      <ReMenuItem
        v-for="item in menu.children"
        :key="item.id"
        :menu="item"
      />
    </ElSubMenu>
  </template>
  <template v-else>
    <!-- 渲染子级菜单 -->
    <ElMenuItem
      class="re-menu-item"
      :index="menu.name"
      @click="handleMenuClick(menu)"
    >
      <template #title>
-        <div>{{ menu.title }}</div>
+        <div>{{ translateText(menu.title) }}</div>
      </template>
    </ElMenuItem>
  </template>
</template>

Router

功能清单

  • 自动识别前进后退
  • 支持路由跳转时传递数据

API

配置项

interface RouterOptions {
  /**
   * Window 对象
   * @default window
   */
  window?: Window
  /**
   * 是否持久化详情记录,开启后会将 `detailsRecord` 存储在 `storage` 中,防止刷新丢失
   *
   * ***注意:持久化后会缓存每次导航的详情信息,默认只在导航后退时清理旧导航信息,也可手动执行 `router.clearDetails()` 清理全部缓存。***
   * @default true
   */
  persistentDetails?: boolean
  /**
   * 存储中心,支持实现 `Storage` 接口的对象
   * @default window.localStorage
   */
  storage?: StorageLikeAsync
  /**
   * 持久化到存储中心的键
   * @default __VUE_APP_SDK__ROUTE_DETAILS_RECORD__
   */
  storageKey?: string
  /**
   * 自定义识别导航方向
   * @param to 目标路由
   * @param from 来源路由
   */
  identifyDirection?: (ctx: {
    to: RouteLocationNormalized
    from: RouteLocationNormalized
    latestPosition: number | null
    currentPosition: number | null
  }) => NavigationDirection
}

初始化

// sdk.ts
import { createAppSDK } from 'vue-app-sdk'

export const sdk = createAppSDK({
  // 配置路由插件
  router: {
    // ...
  }
})

使用方式

在调用 sdk.cleanup() 时自动清理路由跳转数据。

识别前进后退

// App.vue
import { useAppSDK } from 'vue-app-sdk'

const sdk = useAppSDK()
sdk.hook('sdk:router:navigate', (direction) => {
  console.log('路由跳转方向:', direction)
})

sdk.hook('sdk:router:forward', (direction) => {
  console.log('路由前进 +1')
})

sdk.hook('sdk:router:backward', (direction) => {
  console.log('路由后退 +1')
})

sdk.hook('sdk:router:replace', (direction) => {
  console.log('路由刷新 +1')
})
// Page1.vue
import { useRouter } from 'vue-router'

const router = useRouter()
router.push('/Page2')
// Page2.vue
import { useRouter } from 'vue-router'

const router = useRouter()
router.back()

跳转时传递数据

常用于平级跳转时传递数据,支持各种复杂类型数据传递,但出于性能考虑仍不建议传递过大的数据量!

// list.vue
import { useRouter } from 'vue-router'
import { useRouteDetails } from 'vue-app-sdk'

const router = useRouter()
function gotoDetailPage() {
  router.pushWithData('/Detail', { id: 1 })
}

const details = useRouteDetails<{ refreshTable?: boolean }>()
// 持续监听详情页传过来的消息
watch(() => details.data, (data) => {
  // 若详情页通知需要刷新表格则刷新
  if (data.refreshTable)
    refreshTable()
})

function refreshTable() {
  // ...
}
// detail.vue
import { useRouter } from 'vue-router'
import { useRouteDetails } from 'vue-app-sdk'

const details = useRouteDetails<{ id: number }>()
// 打印传过来的 id
console.log(details.data.id)

const router = useRouter()
function handleBack() {
  // 普通返回
  router.back()
}
function handleEdit() {
  // 返回后刷新表格
  router.backWithData({ refreshTable: true })
}

RouterScroller

功能清单

  • 自动还原路由滚动位置
  • 支持移动端和常见中后台管理页面

API

配置项

interface RouterScrollerOptions {
  /**
   * 是否自动收集滚动位置
   * @default true
   */
  autoCollect?: MaybeRefOrGetter<boolean>

  /**
   * 允许捕获的选择器,支持特殊选择器 `window`、`body`
   */
  selectors: Record<string, boolean | RouterScrollHandler>

  /**
   * 默认的滚动行为
   */
  behavior?: ScrollOptions['behavior']

  /**
   * `selectors` 为 `true` 时默认将在导航成功后还原滚动位置,如果设为 `true` 则仅在导航后退时还原,适用于移动端
   * @default false
   */
  scrollOnlyBackward?: boolean
}

实例

interface RouterScroller {
  /**
   * 插件 ID
   */
  id: PluginID<RouterScroller>

  /**
   * 配置项
   */
  options: RouterScrollerOptions

  /**
   * 滚动位置记录
   */
  positions: Map<string, ScrollPositionCoordinatesGroup>

  /**
   * 是否自动模式
   */
  readonly isAuto: boolean

  /**
   * 切换自动模式
   * @param value 状态值
   */
  toggleAuto: (value?: boolean) => boolean

  /**
   * 启用自动模式
   */
  enableAuto: () => boolean

  /**
   * 禁用自动模式
   */
  disableAuto: () => boolean

  /**
   * 由于 `Transition` 动画可能导致元素自动还原滚动无效,此时可手动触发还原滚动
   * @example
   * ```html
   * <script setup lang="ts">
   * import { useAppSDK, ROUTER_SCROLLER_ID } from 'vue-app-sdk'
   *
   * const routerScroller = useAppSDK().getPlugin(ROUTER_SCROLLER_ID)!
   *
   * function handleAfterEnter() {
   *   // 手动触发滚动位置还原
   *   routerScroller.trigger()
   * }
   * </script>
   *
   * <template>
   *   <Transition @after-enter="handleAfterEnter">
   *     ...
   *   </Transition>
   * </template>
   * ```
   */
  trigger: () => Promise<void>

  /**
   * 获取滚动元素
   * @param selector 选择器
   * @returns 滚动元素
   */
  querySelector: (selector: string) => Element | Window | null | undefined

  /**
   * 获取滚动位置
   * @param el 滚动元素
   * @returns 滚动位置
   */
  getScrollPosition: (el: Element | Window) => ScrollPositionCoordinates

  /**
   * 捕获滚动位置
   */
  capturePositions: () => ScrollPositionCoordinatesGroup

  /**
   * 还原滚动位置
   * @param to 目标路由
   * @param from 来源路由
   * @param position 滚动位置
   * @param direction 导航方向
   * @param isManual 是否手动触发
   */
  applyPositions: (
    to: RouteLocationNormalized,
    from: RouteLocationNormalizedLoaded,
    position: ScrollPositionCoordinatesGroup | undefined,
    direction: NavigationDirection,
    isManual?: boolean
  ) => Promise<void>

  /**
   * 插件安装
   */
  install: (sdk: AppSDKInternalInstance) => void
}

初始化

// sdk.ts
import { createAppSDK, createRouterScroller } from 'vue-app-sdk'

export const sdk = createAppSDK()

sdk.use(
  createRouterScroller({
    // 设置需要记录滚动位置的选择器
    selectors: {
      'window': true,
      'body': true,
      '.scrollable': true,
    },
  })
)

使用方式

在调用 sdk.cleanup() 时自动清理路由位置缓存。

基础用法

<!-- App.vue -->
<script setup lang="ts">
import { useAppSDK, ROUTER_SCROLLER_ID } from 'vue-app-sdk'

const routerScroller = useAppSDK().getPlugin(ROUTER_SCROLLER_ID)!

function handleAfterEnter() {
   // 手动触发滚动位置还原
  routerScroller.trigger()
}
</script>

<template>
  <RouterView v-slot="{ Component, route }">
    <Transition
      appear
      name="fade-transform"
      mode="out-in"
      @after-enter="handleAfterEnter"
    >
      <Component
        :is="Component"
        :key="route.fullPath"
       />
    </Transition>
  </RouterView>
</template>

<style scoped>
.fade-transform-enter-active {
  /* ... */
}

.fade-transform-leave-active {
  /* ... */
}
</style>

搭配 Tabs 插件使用

// Tabs.vue
import { ROUTER_SCROLLER_ID, TABS_ID, useAppSDK } from 'vue-app-sdk'

const sdk = useAppSDK()
const [tabs, routerScroller] = sdk.getPlugins(TABS_ID, ROUTER_SCROLLER_ID)

// 注册路由前进事件
sdk.hookScope('sdk:router:forward', (to) => {
  const needRevertPos = tabs!.pages.some(({ fullPath }) => fullPath === to.fullPath)
  // 如果没有相同路径的标签页意味着新开页签,删除掉旧的位置记录
  if (!needRevertPos)
    routerScroller!.positions.delete(to.fullPath)
})

若存在动态开启标签页功能时:

// Main.vue
import { ref, watch } from 'vue'
import { ROUTER_SCROLLER_ID, TABS_ID, useAppSDK } from 'vue-app-sdk'

const [tabs, routerScroller] = useAppSDK().getPlugins(TABS_ID, ROUTER_SCROLLER_ID)
const enableTabs = ref(true)

watch(enableTabs, (value) => {
  if (value) {
    // 开启时关闭滚动器的后退滚动
    routerScroller!.options.scrollOnlyBackward = false

    // 启用时开启标签页的自动收集模式
    tabs!.enableAuto()
  }
  else {
    // 关闭时开启滚动器的后退滚动模式,并清理掉所有的记录信息
    routerScroller!.options.scrollOnlyBackward = true
    routerScroller!.positions.clear()

    // 关闭时禁用标签页的自动收集模式并强制清理所有的标签页数据
    tabs!.disableAuto()
    tabs!.removeAll(true)
  }
}, { immediate: true })

SSO

功能清单

  • 验证是否来源于 SSO
  • 收集 SSO 参数
  • 跳转至 SSO 页面

API

配置项

interface SSOOptions {
  /**
   * 需要验证的参数列表,若存在多种情况可进行分组设置
   * @example
   * ```ts
   * // 不分组配置
   * createSSO({ verifyParams: ['token', { name: 'state', required: false }] })
   *
   * // 分组配置
   * createSSO({
   *   verifyParams: {
   *     success: ['token', { name: 'state', required: false }],
   *     error: ['error'],
   *   }
   * })
   * ```
   */
  verifyParams: VerifyParamType[] | Record<string, VerifyParamType[]>
  /**
   * 获取单点登录地址
   * @example
   * ```ts
   * import { template } from 'nice-fns'
   *
   * // 创建模板编译函数
   * const compiled = template('http://127.0.0.1:3000/login?redirect={{redirectUri}}', {
   *   // 解析 `{{}}`,用于替换实际数据
   *   interpolate: /\{\{([\s\S]+?)\}\}/g
   * })
   *
   * const sso = createSSO({
   *   // 传入重定向地址以获取 SSO 地址
   *   resolveSSOUri: (redirectUri) => compiled({ redirectUri })
   * })
   * ```
   */
  resolveSSOUri: (redirectUri: string) => string
}

实例

interface SSO {
  /**
   * 插件 ID
   */
  id: PluginID<SSO>

  /**
   * 配置项
   */
  options: SSOOptions

  /**
   * 验证路由是否来源于单点登录
   * @param route 路由
   *
   * @example
   * ```ts
   * router.beforeEach((to) => {
   *   if (sso.isFromSSO(to)) {
   *     console.log('来源于 SSO')
   *   }
   * })
   * ```
   */
  isFromSSO: (route: RouteLocationNormalized) => boolean

  /**
   * 获取验证参数
   * @param route 路由
   *
   * @example
   * ```ts
   * router.beforeEach((to) => {
   *   if (sso.isFromSSO(to)) {
   *     const [groupKey, params] = sso.getVerifyParams(to)
   *     console.log('验证分组:', groupKey)
   *     console.log('SSO 参数:', params)
   *   }
   * })
   * ```
   */
  getVerifyParams: <T = LocationQuery>(route: RouteLocationNormalized) => [groupKey: string, params: T]

  /**
   * 跳转至单点登录页
   */
  gotoSSO: () => void

  /**
   * 插件安装
   */
  install: () => void
}

初始化

// sdk.ts
import { createAppSDK, createSSO } from 'vue-app-sdk'

export const sdk = createAppSDK()

sdk.use(
  createSSO({
    // 验证参数
    verifyParams: {
      // 成功分组,必须验证 `token` 是否存在
      success: ['token', { name: 'state', required: false }],
      // 失败分组,必须验证 `error` 是否存在
      error: ['error']
    },

    // 获取 SSO 地址
    resolveSSOUri: (redirectUri) => `http://127.0.0.1:3000/login?redirect_uri=${redirectUri}`,
  }),
)

使用方式

推荐搭配 vue-router 的前置守卫使用!

// router.ts

// ...

router.beforeEach(async (to, from) => {
  const sso = sdk.getPlugin(SSO_ID)!

  // 判断是否来源于 SSO
  if (sso.isFromSSO(to)) {
    // 获取验证参数
    const [groupKey, params] = sso.getVerifyParams(to)

    // 认证失败
    if (groupKey === 'error') {
      // 清理令牌缓存
      token.clear()
      // 提示错误信息
      alert(params.error as string)
      // 前往 SSO 页面认证
      sso.gotoSSO()
      return false
    }
    // 认证成功
    else {
      // 设置访问令牌
      token.set('accessToken', params.token, { format: 'jwt' })
      // 重定向至首页
      return { path: '/home', replace: true }
    }
  }

  // 没有访问令牌时前往 SSO
  if (!token.has('accessToken')) {
    alert('无权访问')
    return sso.gotoSSO()
  }
})

Tabs

此插件基于 Page 插件的数据结构实现,搭配 KeepAlive 可支持标签页缓存功能!

功能清单

  • 自动收集标签页
  • 提供标签页辅助函数

API

配置项

interface TabsOptions {
  /**
   * Window 对象
   * @default window
   */
  window?: Window
  /**
   * 是否持久化数据到 `storage` 中
   * @default true
   */
  persistent?: boolean
  /**
   * 存储中心,支持实现 `Storage` 接口的对象
   * @default window.localStorage
   */
  storage?: StorageLike
  /**
   * 持久化到存储中心的键
   * @default '__VUE_APP_SDK__TABS__'
   */
  storageKey?: string
  /**
   * 相同路由不同参数时处理标签页的模式
   * - `normal`: 普通模式,会打开相同路由不同参数的标签页
   * - `replace`: 替换模式,会先替换旧标签页为新标签页并激活
   * @default 'normal'
   */
  mode?: 'normal' | 'replace'
  /**
   * 是否缓存标签页,可单独设置 `PageMetadata.isKeepAlive` 缓存指定页面
   * @default true
   */
  keepAlive?: boolean
  /**
   * 是否在路由导航时自动收集标签页,设为 `true` 时将使用内置收集器进行收集
   * @default true
   */
  autoCollect?: MaybeRefOrGetter<boolean>
  /**
   * 页面元数据转为标签页属性
   * @param data 页面元数据
   */
  rawPageToTabPage?: (data: PageMetadata) => Omit<TabPage, 'id' | 'fullPath' | 'componentName'>
  /**
   * 是否为有效的页面元数据,指可以被转为标签页的元数据
   * @param data 页面元数据
   * @example
   * ```ts
   * const page: PageMetadata = {
   *   // ...
   *   isFull: true, // 全屏页面,不应被添加到标签页列表
   * }
   *
   * createTabs({
   *   // 排除全屏页面元数据
   *   isValidRawPage: (data) => !data.isFull
   * })
   * ```
   */
  isValidRawPage?: (data: PageMetadata) => boolean
  /**
   * 根据页面元数据添加标签页前的回调处理,若返回假值则取消添加
   */
  beforeAdd?: (page: PageMetadata) => Awaitable<void | boolean>
  /**
   * `mode` 设为 `replace` 时,替换标签页前的回调处理,根据返回结果进行处理
   */
  beforeReplace?: (newTabPage: TabPage, oldTabPage: TabPage) => Awaitable<TabsReplaceResult>
  /**
   * 移除标签页前的回调处理,若返回假值则取消移除
   */
  beforeRemove?: (tabPage: TabPage) => Awaitable<void | boolean>
  /**
   * 处理激活标签页为空的情况
   */
  processEmptyActive?: () => void
}

实例

interface Tabs {
  /**
   * 插件 ID
   */
  id: PluginID<Tabs>

  /**
   * 配置项
   */
  options: TabsOptions

  /**
   * 页面元数据转为标签页属性
   */
  readonly rawPageToTabPage: (data: PageMetadata) => Omit<TabPage, 'fullPath' | 'id' | 'componentName'>

  /**
   * 是否为有效的页面元数据
   */
  readonly isValidRawPage: (data: PageMetadata) => boolean

  /**
   * 标签页列表
   */
  pages: TabPage[]

  /**
   * 激活的标签页 ID
   */
  active: string

  /**
   * 激活的标签页
   */
  readonly activeTabPage: TabPage | undefined

  /**
   * 是否自动模式
   */
  readonly isAuto: boolean

  /**
   * 判断是否需要缓存标签页
   */
  isKeepAlive: (tagPage: { isKeepAlive?: boolean }) => boolean

  /**
   * 生成标签页 ID
   */
  generateID: (data: RouteForGenerableID | TabPageForGenerableID) => string

  /**
   * 切换自动模式
   * @param value 状态值
   */
  toggleAuto: (value?: boolean) => boolean

  /**
   * 启用自动模式
   */
  enableAuto: () => boolean

  /**
   * 禁用自动模式
   */
  disableAuto: () => boolean

  /**
   * 更新 KeepAlive 缓存
   */
  updateKeepAlive: () => Promise<void>

  /**
   * 设置标签页列表
   * @param pages 标签页列表
   */
  setTabPages: (pages: TabPage[]) => void

  /**
   * 根据页面元数据列表设置固定标签页列表
   * @param data 页面元数据列表
   */
  setAffixTabPagesByRawPages: (data: PageMetadata[]) => void

  /**
   * 更新标签页
   * @param target 可获取标签页 ID 的值
   * @param data 标签页属性
   */
  updateTabPage: (target: ResolvableIdType, data: Partial<UnsafeTabPage>) => void

  /**
   * 更新标签页的 `fullPath` 属性
   * @param data 路由数据
   * @param data.query `route.query`
   * @param data.params `route.params`
   * @param source 可获取标签页 ID 的值
   */
  updateFullPath: (
    data?: { query?: Record<string, any>, params?: Record<string, any> },
    source?: ResolvableIdType
  ) => void

  /**
   * 根据页面元数据更新标签页列表
   * @param data 页面元数据列表或映射
   */
  updateTabPagesByRawPages: (data: PageMetadata[] | Record<string, PageMetadata | undefined>) => void

  /**
   * 获取标签页 ID
   * @param value 可获取标签页 ID 的值
   * @returns 标签页 ID
   */
  resolveId: (value: ResolvableIdType) => string

  /**
   * 设置当前激活的标签页 ID
   * @param value 可获取标签页 ID 的值
   */
  setActive: (value: ResolvableIdType) => void

  /**
   * 根据路由创建标签页
   * @param route 路由
   * @returns 标签页
   */
  createTabPage: (route: RouteForGenerableID) => TabPage

  /**
   * 是否可以移除指定标签页
   * @param source 可获取标签页 ID 的值
   */
  canRemove: (source?: ResolvableIdType) => boolean
  /**
   * 是否可以移除非指定的其他非固定标签页
   * @param source 可获取标签页 ID 的值
   */
  canRemoveOther: (source?: ResolvableIdType) => boolean
  /**
   * 是否可以移除指定标签页的指定方向侧非固定标签页
   * @param side 方向侧
   * @param source 可获取标签页 ID 的值
   */
  canRemoveSide: (side: TabsSideType, source?: ResolvableIdType) => boolean
  /**
   * 是否可以移除全部非固定标签页
   */
  canRemoveAll: () => boolean

  /**
   * 尝试移除标签页列表
   * @param pages 标签页列表
   */
  tryRemoveTabPages: (pages: TabPage[]) => Promise<boolean>

  /**
   * 移除激活的标签页
   */
  removeActive: () => Promise<boolean>
  /**
   * 移除指定标签页
   * @param value 可获取标签页 ID 的值
   */
  removeOne: (value: ResolvableIdType) => Promise<boolean>
  /**
   * 移除指定标签页的指定方向侧非固定标签页
   * @param side 指定方向侧
   * @param source 可获取标签页 ID 的值
   */
  removeSide: (side: TabsSideType, source?: ResolvableIdType) => Promise<void>
  /**
   * 移除非指定的其他非固定标签页
   * @param source 可获取标签页 ID 的值
   */
  removeOther: (source?: ResolvableIdType) => Promise<void>
  /**
   * 移除全部非固定标签页
   * @param force 是否强制移除,将不会触发 `beforeRemove` 回调
   */
  removeAll: (force?: boolean) => Promise<void>

  /**
   * 匹配相同标签页
   * @param tabPage 标签页
   */
  matchSameTabPage: (tabPage: TabPage) => {
    /**
     * 模糊匹配结果(同 `pageId`)
     */
    fuzzy: TabPage[]
    /**
     * 精确匹配(同 `pageId`、`fullPath`)
     */
    exact: TabPage | undefined
  }

  /**
   * 根据路由添加标签页
   * @param route 路由
   */
  addOne: (route: RouteForGenerableID & { matched?: RouteLocationNormalized['matched'] }) => Promise<void>

  /**
   * 插件安装
   */
  install: (sdk: AppSDKInternalInstance) => Uninstall
}

初始化

import { TabsReplaceResult, createAppSDK, createTabs } from 'vue-app-sdk'

export const sdk = createAppSDK()

sdk.use(
  createTabs({
    // 使用替换模式
    mode: 'replace',

    // 自动收集标签页
    autoCollect: true,

    // 排除全屏页面
    isValidRawPage: (data) => !data.isFull,

    // 替换前置回调
    beforeReplace: async (tabPage) => {
      // 替换前确认
      const result = await ElMessageBox.confirm(
        `当前已存在【${tabPage.title}】页面,请选择打开方式?`,
        '提示',
        {
          confirmButtonText: '旧标签页',
          cancelButtonText: '新标签页',
          // 区分取消和关闭
          distinguishCancelAndClose: true,
          closeOnClickModal: false,
        }
      ).catch((result) => result)

      switch (result) {
        case 'confirm':
          return TabsReplaceResult.replace
        case 'cancel':
          return TabsReplaceResult.open
        default:
          return TabsReplaceResult.cancel
      }
    },
  })
)

使用方式

在调用 sdk.cleanup() 时自动清理标签页缓存。

标签页渲染

这里使用 ElementPlus 进行开发示范,其他组件库同理。

<!-- layout/components/Tabs.vue -->
<script setup lang="ts">
import type { TabsPaneContext } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { useAppSDK, TABS_ID } from 'vue-app-sdk'
import { useRoute } from 'vue-router'

const userStore = useUserStore()

const sdk = useAppSDK()
const tabs = sdk.getPlugin(TABS_ID)!

// 初始化标签页列表
initTabs()

const route = useRoute()
function initTabs() {
  // 没有标签页时初始化固定标签页
  if (tabs.pages.length === 0)
    tabs.setAffixTabPagesByRawPages(toValue(pages))

  // 没有激活页签时添加当前路由为激活页签
  if (!tabs.active)
    tabs.addOne(route)
}

// 处理标签页点击事件
function handleTabClick(tabItem: TabsPaneContext) {
  // 设置激活的标签页
  tabs.setActive(tabItem.paneName)
}
</script>

<template>
  <ElTabs
    v-model="tabs.active"
    type="card"
    @tab-click="handleTabClick"
    @tab-remove="tabs.removeOne"
  >
    <ElTabPane
      v-for="item in tabs.pages"
      :key="item.id"
      :label="item.title"
      :name="item.id"
      :closable="!item.isAffix"
    >
      <template #label>
        <ElText>
          <ElIcon class="el-icon--left">
            <Component :is="item.icon">
          </ElIcon>
          <span>{{ item.title }}</span>
        </ElText>
      </template>
    </ElTabPane>
  </ElTabs>
</template>

标签页操作

这里使用 ElementPlus 进行开发示范,其他组件库同理。

<!-- layout/components/TabsMoreButton.vue -->
<script setup lang="ts">
import { ArrowDown } from '@element-plus/icons-vue'
import { useAppSDK, TABS_ID } from 'vue-app-sdk'

const tabs = useAppSDK().getPlugin(TABS_ID)!
</script>

<template>
  <ElDropdown
    class="more-button"
    trigger="click"
  >
    <ElButton
      class="trigger"
      :text="true"
    >
      <ElIcon :size="20">
        <ArrowDown />
      </ElIcon>
    </ElButton>
    <template #dropdown>
      <ElDropdownMenu>
        <ElDropdownItem
          :disabled="!tabs.canRemove()"
          @click="tabs.removeActive()"
        >
          关闭当前
        </ElDropdownItem>
        <ElDropdownItem
          :disabled="!tabs.canRemoveSide('left')"
          @click="tabs.removeBySide('left')"
        >
          关闭左侧
        </ElDropdownItem>
        <ElDropdownItem
          :disabled="!tabs.canRemoveSide('right')"
          @click="tabs.removeBySide('right')"
        >
          关闭右侧
        </ElDropdownItem>
        <ElDropdownItem
          divided
          :disabled="!tabs.canRemoveOther()"
          @click="tabs.removeOther()"
        >
          关闭其他
        </ElDropdownItem>
        <ElDropdownItem
          :disabled="!tabs.canRemoveAll()"
          @click="tabs.removeAll()"
        >
          全部关闭
        </ElDropdownItem>
      </ElDropdownMenu>
    </template>
  </ElDropdown>
</template>

<style lang="scss" scoped>
.more-button {
  border-left: 1px solid var(--el-color-info-light-5);

  .trigger {
    width: 100%;
    height: 100%;
    border: none;
    border-radius: 0;
  }
}
</style>

后面只需调整 Tabs.vue 组件结合 TabsMoreButton.vue 即可!

动态开启标签页

// Main.vue
import { ref, watch } from 'vue'
import { TABS_ID, useAppSDK } from 'vue-app-sdk'

const tabs = useAppSDK().getPlugin(TABS_ID)!

// 启用标签页变量
const enableTabs = ref(true)
watch(enableTabs, (value) => {
  if (value) {
    // 启用时开启标签页的自动收集模式
    tabs.enableAuto()
  }
  else {
    // 关闭时禁用标签页的自动收集模式并强制清理所有的标签页数据
    tabs.disableAuto()
    tabs.removeAll(true)
  }
}, { immediate: true })

标签页缓存

依赖于 KeepAlive 实现标签页的缓存管理,只需注册插件即可!

Token

功能清单

  • 支持单一令牌和多令牌缓存
  • 支持 JWT(JSON Web Token) 格式配置

API

配置项

interface TokenOptions {
  /**
   * Window 对象
   * @default window
   */
  window?: Window
  /**
   * 是否持久化数据到 `storage` 中
   * @default true
   */
  persistent?: boolean
  /**
   * 存储中心,支持实现 `Storage` 接口的对象
   * @default window.localStorage
   */
  storage?: StorageLike
  /**
   * 持久化到存储中心的键
   * @default '__VUE_APP_SDK__TOKEN__'
   */
  storageKey?: string
  /**
   * 访问令牌格式
   * - `normal`: 普通格式,`resolve()` 存储即所得
   * - `jwt`: JWT(JSON Web Token) 格式,`resolve()` 返回 `toJWT()` 格式
   *
   * @default 'normal'
   */
  format?: MaybeRefOrGetter<'normal' | 'jwt'>
  /**
   * 默认的 JWT(JSON Web Token) 前缀
   * @default 'Bearer'
   */
  jwtPrefix?: string
}

实例

interface Token {
  /**
   * 插件 ID
   */
  id: PluginID<Token>

  /**
   * 配置项
   */
  options: TokenOptions

  /**
   * 指定令牌是否存在
   * @param type 令牌类型
   * @param key 令牌键
   * @example
   * ```ts
   * // 应用仅存在单令牌
   * token.has('accessToken')
   *
   * // 应用存在多令牌
   * token.has('accessToken', 'second')
   * ```
   */
  has: (type: 'accessToken' | 'refreshToken', key?: string) => boolean

  /**
   * 获取指定令牌
   * @param type 令牌类型
   * @param key 令牌键
   * @example
   * ```ts
   * // 应用仅存在单令牌
   * token.get('accessToken')
   *
   * // 应用存在多令牌
   * token.get('accessToken', 'second')
   * ```
   */
  get: (type: 'accessToken' | 'refreshToken', key?: string) => string | undefined

  /**
   * 设置令牌状态
   * @param type 令牌类型
   * @param value 令牌
   * @param key 令牌键
   * @param profile 令牌配置
   *
   * @example
   * ```ts
   * // 应用仅存在单令牌
   * token.set('accessToken', 'token value', { format: 'jwt' })
   * token.set({ accessToken: 'token value' }, { format: 'jwt' })
   *
   * // 应用存在多令牌
   * token.set('accessToken', 'token value', 'second', { format: 'jwt', jwtPrefix: 'Token' })
   * token.set({ accessToken: 'token value' }, 'second', { format: 'jwt', jwtPrefix: 'Token' })
   * ```
   */
  set: {
    (type: 'accessToken' | 'refreshToken', value?: string, key?: string, profile?: TokenProfile): void
    (type: 'accessToken' | 'refreshToken', value?: string, profile?: TokenProfile): void
    (state: Partial<TokenState<false>>, key?: string, profile?: TokenProfile): void
    (state: Partial<TokenState<false>>, profile?: TokenProfile): void
  }

  /**
   * 清理令牌状态
   * @param type 令牌类型,设为空时清理全部令牌
   * @param key 令牌键,设为空时清理指定类型全部令牌
   * @example
   * ```ts
   * // 应用仅存在单令牌
   * token.clear('accessToken')
   *
   * // 应用存在多令牌
   * token.clear('accessToken', 'second')
   *
   * // 删除指定类型所有令牌
   * token.clear('accessToken', '')
   *
   * // 删除全部令牌
   * token.clear()
   * ```
   */
  clear: (type?: 'accessToken' | 'refreshToken', key?: string) => void

  /**
   * 转为 JWT(JSON Web Token) 格式令牌
   * @param prefix JWT(JSON Web Token) 前缀
   * @param key 令牌键
   * @returns JWT(JSON Web Token) 格式令牌
   * @example
   * ```ts
   * // 使用默认 `jwtPrefix`
   * token.toJWT() // 'Bearer abcdefg'
   *
   * // 使用自定义 `jwtPrefix`
   * token.toJWT('Token') // 'Token abcdefg''
   * ```
   */
  toJWT: (prefix?: string, key?: string) => string

  /**
   * 根据设定令牌格式获取访问令牌
   * @param key 令牌键
   * @example
   * ```ts
   * // 普通格式
   * const token = createToken()
   * token.set('accessToken', 'abcdefg')
   * token.resolve() // 'abcdefg'
   *
   * // JWT 格式
   * const token = createToken({ format: 'jwt' })
   * token.set('accessToken', 'abcdefg')
   * token.resolve() // 'Bearer abcdefg'
   *
   * // 单独设置
   * const token = createToken()
   * token.set('accessToken', 'abcdefg', { format: 'jwt', jwtPrefix: 'Token' })
   * token.resolve() // 'Token abcdefg'
   * ```
   */
  resolve: (key?: string) => string | undefined

  /**
   * 获取令牌配置
   * @param key 令牌键
   */
  getTokenProfile: (key?: string) => TokenProfile

  /**
   * 插件安装
   */
  install: (sdk: AppSDKInternalInstance) => void
}

初始化

// sdk.ts
import { createAppSDK, createToken } from 'vue-app-sdk'

export const sdk = createAppSDK()

sdk.use(
  createToken({
    // `accessToken` 格式
    // - `normal`: 普通格式,`resolve()` 存储即所得
    // - `jwt`: JWT(JSON Web Token) 格式,`resolve()` 返回 `toJWT()` 格式
    format: 'jwt',

    // JWT(JSON Web Token) 前缀
    jwtPrefix: 'Bearer'
  }),
)

使用方式

在调用 sdk.cleanup() 时自动清理令牌缓存。

单令牌管理

import { TOKEN_ID, useAppSDK } from 'vue-app-sdk'
import Axios from 'axios'

const token = useAppSDK().getPlugin(TOKEN_ID)!
const axios = Axios.create()

axios.interceptors.request.use((config) => {
  // 如果有访问令牌则携带
  if (token.has('accessToken')) {
    // 设置最终的访问令牌
    config.headers.Authorization = token.resolve()
  }

  return config
})

login()

async function login() {
  // ...

  const response = await axios<{ accessToken: string, refreshToken?: string }>('/login')
  if (response.status === 200) {
    // 设置令牌信息,刷新令牌非必须
    token.set({
      accessToken: response.data.accessToken,
      refreshToken: response.data.refreshToken
    })
  }
  else {
    // 清除所有令牌信息,可传入指定令牌类型
    token.clear()
  }
}

多令牌管理

  1. 定义令牌键,调用方法时可获得类型提示
// types/vue-app-sdk.d.ts

declare module 'vue-app-sdk' {
  interface TokenRecord {
    // 三方开放接口访问令牌,值类型随意设置
    open: void
  }
}

export {}
  1. 调用方法时传入指定令牌键
// ...

fetchUserInfo().then((data) => {
  // 设置三方开放接口访问令牌和令牌配置
  token.set('accessToken', data.openToken, 'open', { format: 'normal' })

  // 控制台输出三方开放接口访问令牌
  console.log(token.get('accessToken', 'open'))
})

function fetchUserInfo() {
  return Promise.resolve({ id: 1, name: 'admin', openToken: 'abcd' })
}
适配不同接口使用指定令牌

本示例以 axios 为例,其他请求工具可自行尝试。

  1. 定义 axios 全局配置项,获取类型提示
// types/axios.d.ts
import type { TokenKey } from 'vue-app-sdk'

declare module 'axios' {
  interface AxiosRequestConfig {
    /**
     * 令牌键,多令牌时可设置指定访问令牌键
     */
    tokenKey?: TokenKey
  }
}

export {}
  1. 拦截器携带指定令牌键的访问令牌
// ...

axios.interceptors.request.use((config) => {
  // 如果有指定访问令牌则携带
  if (token.has('accessToken', config.tokenKey)) {
    // 设置最终的访问令牌
    config.headers.Authorization = token.resolve(config.tokenKey)
  }

  return config
})

// 请求三方开放接口数据
function fetchData() {
  // 设置令牌键,在接口请求时携带指定令牌键的访问令牌
  return axios.get('/open/list', { tokenKey: 'open' })
}

相关链接