升级概要
- 核心功能优化
- 插件体系重构
- 插件功能增强
- 类型系统优化
- 文档完善
文档目录
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
实例
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
进行开发示范,其他组件库同理。
- 创建递归子菜单组件
<!-- 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>
- 创建菜单组件
<!-- 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
功能清单
- 自动收集标签页
- 提供标签页辅助函数
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()
}
}
多令牌管理
- 定义令牌键,调用方法时可获得类型提示
// types/vue-app-sdk.d.ts
declare module 'vue-app-sdk' {
interface TokenRecord {
// 三方开放接口访问令牌,值类型随意设置
open: void
}
}
export {}
- 调用方法时传入指定令牌键
// ...
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
为例,其他请求工具可自行尝试。
- 定义
axios
全局配置项,获取类型提示
// types/axios.d.ts
import type { TokenKey } from 'vue-app-sdk'
declare module 'axios' {
interface AxiosRequestConfig {
/**
* 令牌键,多令牌时可设置指定访问令牌键
*/
tokenKey?: TokenKey
}
}
export {}
- 拦截器携带指定令牌键的访问令牌
// ...
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' })
}