fix: 完整支持新路由结构

This commit is contained in:
chansee97 2025-09-07 00:47:56 +08:00
parent ab72ff1bdd
commit 423e79f64d
25 changed files with 359 additions and 427 deletions

View File

@ -45,6 +45,10 @@ export function deleteMenu(id: number) {
*
* GET /menu/selectTree
*/
export function getMenuOptions() {
return request.Get<Api.Response<Entity.TreeNode[]>>('/menu/options')
export function getMenuOptions(excludePermissions?: boolean) {
return request.Get<Api.Response<Entity.TreeNode[]>>('/menu/options', {
params: {
excludePermissions,
},
})
}

View File

@ -39,13 +39,13 @@ const options = computed(() => {
return routeStore.rowRoutes.filter((item) => {
const conditions = [
t(`route.${String(item.name)}`, item.title || item.name)?.includes(searchValue.value),
t(`${String(item.i18nKey)}`, item.title)?.includes(searchValue.value),
item.path?.includes(searchValue.value),
]
return conditions.some(condition => !item.hide && condition)
return conditions.some(condition => !item.menuVisible && condition)
}).map((item) => {
return {
label: t(`route.${String(item.name)}`, item.title || item.name),
label: t(`${String(item.i18nKey)}`, item.title),
value: item.path,
icon: item.icon,
}

View File

@ -24,7 +24,7 @@ const emit = defineEmits<{
>
<div class="flex-center gap-2 text-nowrap">
<nova-icon :icon="route.meta.icon" />
<span>{{ $t(`route.${String(route.name)}`, route.meta.title) }}</span>
<span>{{ $t(`${String(route.meta.i18nKey)}`, route.meta.title) }}</span>
<button
v-if="closable"
type="button"

View File

@ -11,8 +11,8 @@ export function setupRouterGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
// 判断是否是外链,如果是直接打开网页并拦截跳转
if (to.meta.href) {
window.open(to.meta.href)
if (to.meta.isLink) {
window.open(to.meta.linkPath)
next(false) // 取消当前导航
return
}
@ -23,7 +23,7 @@ export function setupRouterGuard(router: Router) {
const isLogin = Boolean(local.get('accessToken'))
// 处理根路由重定向
if (to.name === 'root') {
if (to.path === '/') {
if (isLogin) {
// 已登录,重定向到首页
next({ path: import.meta.env.VITE_HOME_PATH, replace: true })
@ -35,22 +35,19 @@ export function setupRouterGuard(router: Router) {
return
}
// 如果是login路由直接放行
if (to.name === 'login') {
// login页面不需要任何认证检查直接放行
}
// 如果路由明确设置了requiresAuth为false直接放行
else if (to.meta.requiresAuth === false) {
// 明确设置为false的路由直接放行
// 继续执行后面的逻辑
}
// 如果路由设置了requiresAuth为true且用户未登录重定向到登录页
else if (to.meta.requiresAuth === true && !isLogin) {
// 如果用户未登录,重定向到登录页
if (!isLogin) {
const redirect = to.name === 'not-found' ? undefined : to.fullPath
next({ path: '/login', query: { redirect } })
return
}
// 如果用户已登录且访问login页面重定向到首页
if (to.name === 'login' && isLogin) {
next({ path: '/' })
return
}
// 判断路由有无进行初始化
if (!routeStore.isInitAuthRoute && to.name !== 'login') {
try {
@ -76,17 +73,11 @@ export function setupRouterGuard(router: Router) {
}
}
// 如果用户已登录且访问login页面重定向到首页
if (to.name === 'login' && isLogin) {
next({ path: '/' })
return
}
next()
})
router.beforeResolve((to) => {
// 设置菜单高亮
routeStore.setActiveMenu(to.meta.activeMenu ?? to.fullPath)
routeStore.setActiveMenu(to.meta.activePath ?? to.fullPath)
// 添加tabs
tabStore.addTab(to)
// 设置高亮标签;

View File

@ -1,4 +1,5 @@
import type { App } from 'vue'
import type { RouteRecord } from 'vue-router'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { setupRouterGuard } from './guard'
import { routes } from './routes.inner'
@ -6,7 +7,7 @@ import { routes } from './routes.inner'
const { VITE_ROUTE_MODE = 'hash', VITE_BASE_URL } = import.meta.env
export const router = createRouter({
history: VITE_ROUTE_MODE === 'hash' ? createWebHashHistory(VITE_BASE_URL) : createWebHistory(VITE_BASE_URL),
routes,
routes: routes as unknown as RouteRecord[],
})
// 安装vue路由
export async function installRouter(app: App) {

View File

@ -1,17 +1,42 @@
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/layouts/index.vue'
/* 页面中的一些固定路由,错误页等 */
export const routes: RouteRecordRaw[] = [
export const routes = [
{
path: '/',
name: 'root',
path: '/appRoot',
name: 'appRoot',
redirect: import.meta.env.VITE_HOME_PATH,
component: Layout,
meta: {
title: '',
icon: 'icon-park-outline:home',
},
children: [
{
path: '/user-center',
name: 'userCenter',
component: () => import('@/views/build-in/user-center/index.vue'),
meta: {
title: '个人中心',
icon: 'carbon:user-avatar-filled-alt',
},
},
{
path: '/home',
name: 'home',
component: () => import('@/views/build-in/home/index.vue'),
meta: {
title: '首页',
icon: 'icon-park-outline:analysis',
pinTab: true,
},
},
],
},
{
path: '/login',
name: 'login',
component: () => import('@/views/build-in/login/index.vue'), // 注意这里要带上 文件后缀.vue
component: () => import('@/views/build-in/login/index.vue'),
meta: {
title: '登录',
withoutTab: true,

View File

@ -1,14 +1,4 @@
export const staticRoutes: Entity.Menu[] = [
{
path: '/home',
title: '概览',
icon: 'icon-park-outline:analysis',
pinTab: true,
menuType: 'page',
component: '/build-in/home/index.vue',
id: 1,
parentId: 0,
},
{
path: '/multi',
title: '多级菜单演示',
@ -30,7 +20,7 @@ export const staticRoutes: Entity.Menu[] = [
path: '/multi/multi-2/detail',
title: '菜单详情页',
icon: 'icon-park-outline:list',
menuVisible: true,
menuVisible: false,
activePath: '/multi/multi-2',
menuType: 'page',
component: '/demo/multi/multi-2/detail/index.vue',
@ -50,6 +40,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '多级菜单3-1',
icon: 'icon-park-outline:list',
component: '/demo/multi/multi-3/multi-4/index.vue',
menuType: 'page',
id: 20201,
parentId: 202,
},
@ -66,6 +57,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '卡片列表',
icon: 'icon-park-outline:view-grid-list',
component: '/demo/list/card-list/index.vue',
menuType: 'page',
id: 302,
parentId: 3,
},
@ -74,6 +66,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '拖拽列表',
icon: 'icon-park-outline:menu-fold',
component: '/demo/list/draggable-list/index.vue',
menuType: 'page',
id: 303,
parentId: 3,
},
@ -90,6 +83,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '请求示例',
icon: 'icon-park-outline:international',
component: '/demo/fetch/index.vue',
menuType: 'page',
id: 401,
parentId: 4,
},
@ -98,6 +92,7 @@ export const staticRoutes: Entity.Menu[] = [
title: 'ECharts',
icon: 'icon-park-outline:chart-proportion',
component: '/demo/echarts/index.vue',
menuType: 'page',
id: 402,
parentId: 4,
},
@ -107,6 +102,7 @@ export const staticRoutes: Entity.Menu[] = [
icon: 'carbon:map',
keepAlive: true,
component: '/demo/map/index.vue',
menuType: 'page',
id: 403,
parentId: 4,
},
@ -123,6 +119,7 @@ export const staticRoutes: Entity.Menu[] = [
title: 'MarkDown',
icon: 'ri:markdown-line',
component: '/demo/editor/md/index.vue',
menuType: 'page',
id: 40401,
parentId: 404,
},
@ -131,6 +128,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '富文本',
icon: 'icon-park-outline:edit-one',
component: '/demo/editor/rich/index.vue',
menuType: 'page',
id: 40402,
parentId: 404,
},
@ -139,6 +137,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '剪贴板',
icon: 'icon-park-outline:clipboard',
component: '/demo/clipboard/index.vue',
menuType: 'page',
id: 405,
parentId: 4,
},
@ -147,6 +146,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '图标',
icon: 'local:cool',
component: '/demo/icons/index.vue',
menuType: 'page',
id: 406,
parentId: 4,
},
@ -155,6 +155,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '二维码',
icon: 'icon-park-outline:two-dimensional-code',
component: '/demo/qr-code/index.vue',
menuType: 'page',
id: 407,
parentId: 4,
},
@ -163,6 +164,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '省市区联动',
icon: 'icon-park-outline:add-subset',
component: '/demo/cascader/index.vue',
menuType: 'page',
id: 408,
parentId: 4,
},
@ -171,6 +173,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '字典示例',
icon: 'icon-park-outline:book-one',
component: '/demo/dict/index.vue',
menuType: 'page',
id: 409,
parentId: 4,
},
@ -187,6 +190,7 @@ export const staticRoutes: Entity.Menu[] = [
title: 'Vue',
icon: 'logos:vue',
component: '/demo/documents/vue/index.vue',
menuType: 'page',
id: 501,
parentId: 5,
},
@ -195,25 +199,30 @@ export const staticRoutes: Entity.Menu[] = [
title: 'Vite',
icon: 'logos:vitejs',
component: '/demo/documents/vite/index.vue',
menuType: 'page',
id: 502,
parentId: 5,
},
{
path: 'https://vueuse.org/guide/',
path: '/VueUse',
title: 'VueUse外链',
icon: 'logos:vueuse',
isLink: true,
id: 503,
parentId: 5,
isLink: true,
linkPath: 'https://vueuse.org/guide/',
menuType: 'page',
},
{
path: 'https://nova-admin-docs.netlify.app/',
title: 'Nova docs',
path: '/Nova',
title: 'Nova docs(外链)',
icon: 'local:logo',
isLink: true,
id: 504,
parentId: 5,
isLink: true,
linkPath: 'https://nova-admin-docs.netlify.app/',
menuType: 'page',
},
{
path: '/public',
@ -222,6 +231,8 @@ export const staticRoutes: Entity.Menu[] = [
isLink: true,
id: 505,
parentId: 5,
linkPath: '/public',
menuType: 'page',
},
{
path: '/permission',
@ -236,6 +247,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '权限示例',
icon: 'icon-park-outline:right-user',
component: '/demo/permission/permission/index.vue',
menuType: 'page',
id: 601,
parentId: 6,
},
@ -244,6 +256,7 @@ export const staticRoutes: Entity.Menu[] = [
title: 'super可见',
icon: 'icon-park-outline:wrong-user',
component: '/demo/permission/just-super/index.vue',
menuType: 'page',
id: 602,
parentId: 6,
},
@ -260,6 +273,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '用户设置',
icon: 'icon-park-outline:every-user',
component: '/system/user/index.vue',
menuType: 'page',
id: 701,
parentId: 7,
},
@ -268,6 +282,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '角色设置',
icon: 'icon-park-outline:every-user',
component: '/system/role/index.vue',
menuType: 'page',
id: 702,
parentId: 7,
},
@ -276,6 +291,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '字典设置',
icon: 'icon-park-outline:book-one',
component: '/system/dict/index.vue',
menuType: 'page',
id: 703,
parentId: 7,
},
@ -284,6 +300,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '菜单设置',
icon: 'icon-park-outline:application-menu',
component: '/system/menu/index.vue',
menuType: 'page',
id: 704,
parentId: 7,
},
@ -292,6 +309,7 @@ export const staticRoutes: Entity.Menu[] = [
title: '部门管理',
icon: 'icon-park-outline:application-menu',
component: '/system/dept/index.vue',
menuType: 'page',
id: 705,
parentId: 7,
},
@ -300,17 +318,8 @@ export const staticRoutes: Entity.Menu[] = [
title: '关于',
icon: 'icon-park-outline:info',
component: '/demo/about/index.vue',
menuType: 'page',
id: 8,
parentId: 0,
},
{
path: '/user-center',
title: '个人中心',
menuVisible: true,
icon: 'carbon:user-avatar-filled-alt',
component: '/build-in/user-center/index.vue',
id: 999,
parentId: 0,
},
]

View File

@ -5,7 +5,7 @@ import { useRouteStore } from './router'
import { useTabStore } from './tab'
interface AuthStatus {
userInfo: Entity.User | Record<string, any>
userInfo: Entity.User
roles: string[]
permissions: string[]
}
@ -65,7 +65,13 @@ export const useAuthStore = defineStore('auth-store', {
const { data } = await fetchLogin(loginData)
// 处理登录信息
await this.handleLoginInfo(data)
try {
await this.handleLoginInfo(data)
}
catch (error) {
console.error('Failed to handle login info:', error)
throw error
}
// 更新用户信息
await this.updataUserInfo()

View File

@ -1,88 +1,66 @@
/// <reference path="../../typings/global.d.ts" />
import type { MenuOption } from 'naive-ui'
import type { RouteRecordRaw } from 'vue-router'
import { usePermission } from '@/hooks'
import Layout from '@/layouts/index.vue'
import { $t, renderIcon } from '@/utils'
import { clone, min, omit, pick } from 'radash'
import { clone, isEmpty, min, pick } from 'radash'
import { RouterLink } from 'vue-router'
import arrayToTree from 'array-to-tree'
const metaFields: AppRoute.MetaKeys[]
= ['title', 'icon', 'requiresAuth', 'roles', 'keepAlive', 'hide', 'order', 'href', 'activeMenu', 'withoutTab', 'pinTab', 'menuType']
function standardizedRoutes(route: AppRoute.RowRoute[]) {
return clone(route).map((i) => {
const route = omit(i, metaFields)
Reflect.set(route, 'meta', pick(i, metaFields))
return route
}) as AppRoute.Route[]
}
export function createRoutes(routes: AppRoute.RowRoute[]) {
const { hasPermission } = usePermission()
const metaFields: (keyof Entity.Menu)[]
= ['title', 'icon', 'keepAlive', 'activePath', 'tabVisible', 'pinTab', 'menuType', 'linkPath', 'isLink', 'i18nKey']
export function createRoutes(routes: Entity.Menu[]) {
// Structure the meta field
let resultRouter = standardizedRoutes(routes)
// Route permission filtering
resultRouter = resultRouter.filter(i => hasPermission(i.meta.roles))
const routerWithMeta: App.RouteRecord[] = clone(routes).map((i) => {
const meta = pick(i, metaFields)
return {
name: `${i.title}_${i.id}`,
path: i.path,
component: i.component,
meta,
id: i.id,
parentId: i.parentId || 0,
}
})
// Generate routes, no need to import files for those with redirect
const modules = import.meta.glob('@/views/**/*.vue')
resultRouter = resultRouter.map((item: AppRoute.Route) => {
if (item.component && !item.redirect)
let resultRouter = routerWithMeta.map((item) => {
if (item.component)
item.component = modules[`/src/views${item.component}`]
return item
})
// Generate route tree
resultRouter = arrayToTree(resultRouter, {
parentProperty: 'parentId',
}) as AppRoute.Route[]
const appRootRoute: RouteRecordRaw = {
path: '/appRoot',
name: 'appRoot',
redirect: import.meta.env.VITE_HOME_PATH,
component: Layout,
meta: {
title: '',
icon: 'icon-park-outline:home',
},
children: [],
}
})
// Set the correct redirect path for the route
setRedirect(resultRouter)
// Insert the processed route into the root route
appRootRoute.children = resultRouter as unknown as RouteRecordRaw[]
return appRootRoute
return resultRouter
}
// Generate an array of route names that need to be kept alive
export function generateCacheRoutes(routes: AppRoute.RowRoute[]) {
export function generateCacheRoutes(routes: Entity.Menu[]) {
return routes
.filter(i => i.keepAlive)
.map(i => i.name)
.map(i => i.title)
}
function setRedirect(routes: AppRoute.Route[]) {
function setRedirect(routes: App.RouteRecord[]) {
routes.forEach((route) => {
if (route.children) {
if (!route.redirect) {
// Filter out a collection of child elements that are not hidden
const visibleChilds = route.children.filter(child => !child.meta.hide)
const visibleChilds = route.children.filter(child => !child.meta?.menuVisible)
// Redirect page to the path of the first child element by default
let target = visibleChilds[0]
// Filter out pages with the order attribute
const orderChilds = visibleChilds.filter(child => child.meta.order)
const orderChilds = visibleChilds.filter(child => !isEmpty(child.meta.sort))
if (orderChilds.length > 0)
target = min(orderChilds, i => i.meta.order!) as AppRoute.Route
target = min(orderChilds, i => i.meta.sort!)!
if (target)
route.redirect = target.path
@ -94,31 +72,36 @@ function setRedirect(routes: AppRoute.Route[]) {
}
/* 生成侧边菜单的数据 */
export function createMenus(userRoutes: AppRoute.RowRoute[]) {
const resultMenus = standardizedRoutes(userRoutes)
// filter menus that do not need to be displayed
const visibleMenus = resultMenus.filter(route => !route.meta.hide)
export function createMenus(userRoutes: Entity.Menu[]): MenuOption[] {
const menus = transformAuthRoutesToMenus(userRoutes)
// generate side menu
return arrayToTree(transformAuthRoutesToMenus(visibleMenus), {
return arrayToTree(menus, {
parentProperty: 'parentId',
})
}
// render the returned routing table as a sidebar
function transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]) {
const { hasPermission } = usePermission()
function transformAuthRoutesToMenus(userRoutes: Entity.Menu[]) {
const homeRoute: Entity.Menu = {
id: 9999999999999,
parentId: 0,
path: '/home',
title: '首页',
icon: 'icon-park-outline:analysis',
menuVisible: true,
menuType: 'page',
}
userRoutes.unshift(homeRoute)
return userRoutes
// Filter out side menus without permission
.filter(i => hasPermission(i.meta.roles))
// filter menus that do not need to be displayed
.filter(route => route.menuVisible !== false)
// Sort the menu according to the order size
.sort((a, b) => {
if (a.meta && a.meta.order && b.meta && b.meta.order)
return a.meta.order - b.meta.order
else if (a.meta && a.meta.order)
if (a.sort && b.sort)
return a.sort - b.sort
else if (a.sort)
return -1
else if (b.meta && b.meta.order)
else if (b.sort)
return 1
else return 0
})
@ -128,7 +111,7 @@ function transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]) {
id: item.id,
parentId: item.parentId,
label:
(!item.meta.menuType || item.meta.menuType === 'page')
(item.menuType !== 'directory')
? () =>
h(
RouterLink,
@ -137,11 +120,11 @@ function transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]) {
path: item.path,
},
},
{ default: () => $t(`route.${String(item.name)}`, item.meta.title) },
{ default: () => $t(String(item.i18nKey), item.title) },
)
: () => $t(`route.${String(item.name)}`, item.meta.title),
: () => $t(String(item.i18nKey), item.title),
key: item.path,
icon: item.meta.icon ? renderIcon(item.meta.icon) : undefined,
icon: item.icon ? renderIcon(item.icon) : undefined,
}
return target
})

View File

@ -41,9 +41,6 @@ export const useRouteStore = defineStore('route-store', {
try {
// Get user's route
const { data } = await fetchUserMenus()
if (!data) {
throw new Error('Failed to fetch user routes')
}
return data
}
@ -59,7 +56,6 @@ export const useRouteStore = defineStore('route-store', {
},
async initAuthRoute() {
this.isInitAuthRoute = false
try {
// Initialize route information
const rowRoutes = await this.initRouteInfo()
@ -72,7 +68,10 @@ export const useRouteStore = defineStore('route-store', {
// Generate actual route and insert
const routes = createRoutes(rowRoutes)
router.addRoute(routes)
// Add each route as a child of appRoot
routes.forEach((route) => {
router.addRoute('appRoot', route as any)
})
// Generate side menu
this.menus = createMenus(rowRoutes)

View File

@ -9,7 +9,7 @@ namespace Entity {
/**
*
*/
component?: string
component?: string | (() => Promise<unknown>)
/**
*
*/
@ -33,7 +33,7 @@ namespace Entity {
/**
*
*/
menuType?: MenuType
menuType: MenuType
/**
* ID
*/
@ -50,6 +50,10 @@ namespace Entity {
*
*/
perms?: string
/**
*
*/
linkPath?: string
/**
*
*/

View File

@ -48,9 +48,13 @@ declare namespace NaiveUI {
type ThemeColor = 'default' | 'error' | 'primary' | 'info' | 'success' | 'warning'
}
declare namespace Storage {
declare namespace App {
type lang = 'zhCN' | 'enUS'
interface Session {
dict: DictMap
dict: {
[key: string]: Entity.Dict[]
}
}
interface Local {
@ -65,12 +69,13 @@ declare namespace Storage {
/* 存储当前语言 */
lang: App.lang
}
}
declare namespace App {
type lang = 'zhCN' | 'enUS'
}
interface DictMap {
[key: string]: Entity.Dict[]
interface RouteRecord {
name: string
path: string
redirect?: string
component?: string | (() => Promise<unknown>)
meta: Entity.Menu
children?: RouteRecord[]
}
}

View File

@ -1,76 +0,0 @@
declare namespace AppRoute {
type MenuType = 'directory' | 'page' | 'permission'
/** 单个路由所携带的meta标识 */
interface RouteMeta {
/* 页面标题,通常必选。 */
title: string
/* 图标,一般配合菜单使用 */
icon?: string
/* 是否需要登录权限。 */
requiresAuth?: boolean
/* 可以访问的角色 */
roles?: Entity.RoleType[]
/* 是否开启页面缓存 */
keepAlive?: boolean
/* 菜单显示状态 - 适配新菜单实体 */
menuVisible?: boolean
/* 菜单排序 - 适配新菜单实体 */
sort?: number
/* 是否为外链 - 适配新菜单实体 */
isLink?: boolean
/* 高亮菜单路径 - 适配新菜单实体 */
activePath?: string
/* 标签栏显示状态 - 适配新菜单实体 */
tabVisible?: boolean
/** 当前路由是否会被固定在Tab中,用于一些常驻页面 */
pinTab?: boolean
/** 当前路由在左侧菜单是目录还是页面,不设置默认为page */
menuType?: MenuType
/* 以下字段保持向后兼容 */
/* 有些路由我们并不想在菜单中显示,比如某些编辑页面。 */
hide?: boolean
/* 菜单排序。 */
order?: number
/* 嵌套外链 */
href?: string
/** 当前路由不在左侧菜单显示,但需要高亮某个菜单的情况 */
activeMenu?: string
/** 当前路由是否会被添加到Tab中 */
withoutTab?: boolean
}
type MetaKeys = keyof RouteMeta
interface baseRoute {
/** 路由名称(路由唯一标识) */
name: string
/** 路由路径 */
path: string
/** 路由重定向 */
redirect?: string
/* 页面组件地址 */
component?: string | null
/* 路由id */
id: number
/* 父级路由id顶级页面为null */
parentId: number | null
}
/** 单个路由的类型结构(动态路由模式:后端返回此类型结构的路由) */
type RowRoute = RouteMeta & baseRoute
/**
*
*/
interface Route extends baseRoute {
/** 子路由 */
children?: Route[]
/* 页面组件 */
component: any
/** 路由描述 */
meta: RouteMeta
}
}

View File

@ -1,5 +1,6 @@
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta extends AppRoute.RouteMeta {}
interface RouteMeta extends Entity.Menu {
}
}

View File

@ -7,7 +7,7 @@ interface StorageData<T> {
/**
* LocalStorage部分操作
*/
function createLocalStorage<T extends Storage.Local>() {
function createLocalStorage<T extends App.Local>() {
// 默认缓存期限为7天
function set<K extends keyof T>(key: K, value: T[K], expire: number = 60 * 60 * 24 * 7) {
@ -52,7 +52,7 @@ function createLocalStorage<T extends Storage.Local>() {
* sessionStorage部分操作
*/
function createSessionStorage<T extends Storage.Session>() {
function createSessionStorage<T extends App.Session>() {
function set<K extends keyof T>(key: K, value: T[K]) {
const json = JSON.stringify(value)
window.sessionStorage.setItem(`${STORAGE_PREFIX}${String(key)}`, json)

View File

@ -7,7 +7,7 @@ const dictStore = useDictStore()
const selectedDictType = ref('')
const selectedDictValue = ref('')
const dictTypeOptions = ref<Array<{ label: string; value: string }>>([])
const dictTypeOptions = ref<Array<{ label: string, value: string }>>([])
const displayData = ref<Entity.DictData[] | Record<string, any>>([])
// 使 useDict hook
@ -18,7 +18,7 @@ const dictUtils = computed(() => {
enumMap: ref({}),
valueMap: ref({}),
labelMap: ref({}),
options: ref([])
options: ref([]),
}
}
return useDict(selectedDictType.value)
@ -81,7 +81,8 @@ function showOptions() {
// 使 enumMap
const dictLabel = computed(() => {
if (!selectedDictValue.value || !dictUtils.value.enumMap.value) return '--'
if (!selectedDictValue.value || !dictUtils.value.enumMap.value)
return '--'
return dictUtils.value.enumMap.value[selectedDictValue.value] || '--'
})
@ -104,19 +105,19 @@ function removeCache() {
<n-card title="字典演示">
<n-flex vertical>
<n-flex align="center">
<n-select
v-model:value="selectedDictType"
:options="dictTypeOptions"
placeholder="请选择字典类型"
@update:value="changeSelect"
<n-select
v-model:value="selectedDictType"
:options="dictTypeOptions"
placeholder="请选择字典类型"
@update:value="changeSelect"
/>
<n-select
v-model:value="selectedDictValue"
:options="dictUtils.options.value"
placeholder="请选择字典项"
<n-select
v-model:value="selectedDictValue"
:options="dictUtils.options.value"
placeholder="请选择字典项"
/>
</n-flex>
<n-flex>
<n-button @click="showRawData">
显示原始数据 (rawData)
@ -134,36 +135,62 @@ function removeCache() {
显示选项格式 (options)
</n-button>
</n-flex>
<n-flex>
<n-button @click="clearCache" type="warning">
<n-button type="warning" @click="clearCache">
清理所有缓存
</n-button>
<n-button @click="removeCache" type="error" :disabled="!selectedDictType">
<n-button type="error" :disabled="!selectedDictType" @click="removeCache">
移除当前字典缓存
</n-button>
</n-flex>
<pre class="bg-gray-100 p-4 rounded text-sm overflow-auto max-h-96">
{{ JSON.stringify(displayData, null, 2) }}</pre>
{{ JSON.stringify(displayData, null, 2) }}</pre>
<n-flex align="center" v-if="selectedDictValue">
<n-flex v-if="selectedDictValue" align="center">
<n-text>选中值: {{ selectedDictValue }}</n-text>
<n-text type="info">
对应标签: {{ dictLabel }}
</n-text>
</n-flex>
<n-divider />
<n-text depth="3">useDict Hook 使用说明</n-text>
<n-text depth="3">
useDict Hook 使用说明
</n-text>
<n-ul>
<n-li><n-text code>useDict(dictType)</n-text> - 使用字典类型获取字典工具对象</n-li>
<n-li><n-text code>rawData</n-text> - 原始字典数据数组 Entity.DictData[]</n-li>
<n-li><n-text code>enumMap</n-text> - 枚举映射 { value: name }</n-li>
<n-li><n-text code>valueMap</n-text> - 值映射 { value: dictData }</n-li>
<n-li><n-text code>labelMap</n-text> - 标签映射 { name: dictData }</n-li>
<n-li><n-text code>options</n-text> - 选项数组 [{ label: name, value }]</n-li>
<n-li>
<n-text code>
useDict(dictType)
</n-text> - 使用字典类型获取字典工具对象
</n-li>
<n-li>
<n-text code>
rawData
</n-text> - 原始字典数据数组 Entity.DictData[]
</n-li>
<n-li>
<n-text code>
enumMap
</n-text> - 枚举映射 { value: name }
</n-li>
<n-li>
<n-text code>
valueMap
</n-text> - 值映射 { value: dictData }
</n-li>
<n-li>
<n-text code>
labelMap
</n-text> - 标签映射 { name: dictData }
</n-li>
<n-li>
<n-text code>
options
</n-text> - 选项数组 [{ label: name, value }]
</n-li>
<n-li>字典数据会自动缓存60分钟支持手动清理缓存</n-li>
<n-li>推荐使用 useDict hook 而不是直接调用 API store</n-li>
</n-ul>

View File

@ -6,7 +6,7 @@ const { modifyTab } = useTabStore()
const { fullPath, query } = useRoute()
modifyTab(fullPath, (target) => {
target.meta.title = `详情页${query.id}`
target.meta.title = `详情页${query.id || ''}`
})
</script>

View File

@ -5,13 +5,13 @@ const router = useRouter()
<template>
<n-card class="h-130vh">
这个页面包含了一个不在侧边菜单的详情页面
<n-button @click="router.push({ path: '/multi/multi2/detail', query: { id: 1 } })">
<n-button @click="router.push({ path: '/multi/multi-2/detail', query: { id: 1 } })">
跳转详情子页1
</n-button>
<n-button @click="router.push({ path: '/multi/multi2/detail', query: { id: 2 } })">
<n-button @click="router.push({ path: '/multi/multi-2/detail', query: { id: 2 } })">
跳转详情子页2
</n-button>
<n-button @click="router.push({ path: '/multi/multi2/detail', query: { id: 3 } })">
<n-button @click="router.push({ path: '/multi/multi-2/detail', query: { id: 3 } })">
跳转详情子页3
</n-button>
</n-card>

View File

@ -5,8 +5,6 @@ import { useAuthStore } from '@/store'
const authStore = useAuthStore()
const { hasPermission } = usePermission()
const roleList: Entity.RoleType[] = ['super', 'admin', 'user']
function toggleUserRole(role: Entity.RoleType) {
authStore.login(role, '123456')
}
@ -14,7 +12,7 @@ function toggleUserRole(role: Entity.RoleType) {
<template>
<n-card title="权限示例">
<n-h1> 当前权限{{ authStore.userInfo!.roles.map(r => r.roleName) }}</n-h1>
<n-h1> 当前权限{{ authStore.userInfo.roles.map((r: Entity.Role) => r.roleName) }}</n-h1>
<n-button-group>
<n-button v-for="item in roleList" :key="item" type="default" @click="toggleUserRole(item)">
{{ item }}
@ -22,10 +20,10 @@ function toggleUserRole(role: Entity.RoleType) {
</n-button-group>
<n-h2>v-permission 指令用法</n-h2>
<n-space>
<n-button v-permission="['super']">
<n-button v-role="['super']">
仅super可见
</n-button>
<n-button v-permission="['admin']">
<n-button v-role="['admin']">
admin可见
</n-button>
</n-space>

View File

@ -36,7 +36,6 @@ export function createMenuColumns(actions: MenuColumnActions): DataTableColumns<
{
title: '菜单名称',
key: 'title',
width: 400,
},
{
title: '图标',

View File

@ -1,43 +0,0 @@
<template>
<pro-input
title="国际化标识Key"
path="i18nKey"
placeholder="Eg: system.user"
/>
<pro-field
title="菜单图标"
path="icon"
>
<template #input="{ inputProps }">
<icon-select
:value="inputProps.value"
@update:value="inputProps.onUpdateValue"
/>
</template>
</pro-field>
<pro-input
required
title="路由路径"
tooltip="目录类型的路由路径,如:/system"
path="path"
class="col-span-2"
placeholder="Eg: /system"
/>
<pro-digit
title="排序"
tooltip="数字越小,同级中越靠前"
path="sort"
/>
<pro-switch
title="启用"
path="status"
:field-props="{
checkedValue: 0,
uncheckedValue: 1,
}"
/>
<pro-switch
title="菜单可见"
path="menuVisible"
/>
</template>

View File

@ -2,9 +2,6 @@
import { useBoolean } from '@/hooks'
import { createMenu, getMenuById, getMenuOptions, updateMenu } from '@/api'
import { createProModalForm } from 'pro-naive-ui'
import DirectoryForm from './DirectoryForm.vue'
import PageForm from './PageForm.vue'
import PermissionForm from './PermissionForm.vue'
interface Props {
modalName?: string
@ -49,21 +46,8 @@ const modalTitle = computed(() => {
const treeData = ref<Entity.TreeNode[]>([])
//
const formComponents = {
directory: DirectoryForm,
page: PageForm,
permission: PermissionForm,
}
// 使
const currentFormComponent = computed(() => {
const menuType = modalForm.values.value?.menuType || 'directory'
return formComponents[menuType as keyof typeof formComponents]
})
async function openModal(type: ModalType = 'add', data?: Partial<Entity.Menu>) {
getMenuOptions().then((res) => {
getMenuOptions(true).then((res) => {
treeData.value = res.data
})
@ -76,6 +60,14 @@ async function openModal(type: ModalType = 'add', data?: Partial<Entity.Menu>) {
modalForm.values.value.parentId = data.id
modalForm.values.value.path = `${data.path}/`
}
if (data?.menuType === 'directory') {
modalForm.values.value.menuType = 'page'
}
if (data?.menuType === 'page') {
modalForm.values.value.menuType = 'permission'
}
},
async edit() {
if (!data?.id)
@ -123,6 +115,9 @@ async function submitModal(filedValues: Partial<Entity.Menu>) {
}
}
function handlePathChange(path: string) {
modalForm.values.value.component = `${path}/index.vue`
}
defineExpose({
openModal,
})
@ -163,15 +158,119 @@ defineExpose({
/>
<pro-input
required
title="标题"
title="名称"
path="title"
/>
<component :is="currentFormComponent" />
<pro-input
v-if="modalForm.values.value.menuType !== 'permission'"
title="国际化标识Key"
path="i18nKey"
placeholder="Eg: system.user"
/>
<pro-field
v-if="modalForm.values.value.menuType !== 'permission'"
title="菜单图标"
path="icon"
>
<template #input="{ inputProps }">
<icon-select
:value="inputProps.value"
@update:value="inputProps.onUpdateValue"
/>
</template>
</pro-field>
<pro-input
v-if="modalForm.values.value.menuType !== 'permission'"
required
title="路由路径"
tooltip="页面路由路径,与组件路径对应"
path="path"
class="col-span-2"
placeholder="Eg: /system/user"
@change="handlePathChange"
/>
<pro-input
v-if="modalForm.values.value.menuType === 'page'"
required
title="组件路径"
tooltip="页面组件的文件路径"
path="component"
class="col-span-2"
placeholder="Eg: /system/user/index.vue"
/>
<pro-digit
title="排序"
tooltip="数字越小,同级中越靠前, 默认为0"
path="sort"
/>
<pro-input
title="权限标识"
tooltip="需与后端装饰器一致,如@RequirePermissions('system:user:list')"
path="perms"
placeholder="Eg: system:user:list"
/>
<pro-switch
title="启用"
path="status"
:field-props="{
checkedValue: 0,
uncheckedValue: 1,
}"
/>
<pro-textarea
title="备注"
path="remark"
class="col-span-2"
/>
<n-collapse v-if="modalForm.values.value.menuType === 'page'" class="col-span-2">
<n-collapse-item>
<template #header>
<icon-park-outline-setting class="mr-2" />
高级设置
</template>
<div class="grid grid-cols-2">
<pro-input
title="高亮菜单路径"
tooltip="当前路由不在侧边菜单显示,但需要高亮为某个菜单"
path="activePath"
class="col-span-2"
placeholder="Eg: /system/user"
/>
<pro-switch
title="菜单可见"
path="menuVisible"
/>
<pro-switch
title="标签栏可见"
path="tabVisible"
/>
<pro-switch
title="页面缓存"
tooltip="开启配置后,切换页面数据不会清空"
path="keepAlive"
/>
<pro-switch
title="常驻标签栏"
path="pinTab"
/>
<pro-switch
title="跳转外链"
tooltip="开启配置后,点击菜单会跳转到外链地址"
path="isLink"
/>
<pro-input
v-if="modalForm.values.value.isLink"
required
title="外链地址"
tooltip="开启跳转外链配置后,点击菜单会跳转到外链地址"
path="linkPath"
class="col-span-2"
placeholder="Eg: https://www.baidu.com"
/>
</div>
</n-collapse-item>
</n-collapse>
</div>
</pro-modal-form>
</template>

View File

@ -1,83 +0,0 @@
<template>
<pro-input
title="国际化标识Key"
path="i18nKey"
placeholder="Eg: system.user"
/>
<pro-field
title="菜单图标"
path="icon"
>
<template #input="{ inputProps }">
<icon-select
:value="inputProps.value"
@update:value="inputProps.onUpdateValue"
/>
</template>
</pro-field>
<pro-input
required
title="路由路径"
tooltip="页面路由路径,与组件路径对应"
path="path"
class="col-span-2"
placeholder="Eg: /system/user"
@update:value="$emit('path', $event)"
/>
<pro-input
title="高亮菜单路径"
tooltip="当前路由不在侧边菜单显示,但需要高亮为某个菜单"
path="activePath"
class="col-span-2"
placeholder="Eg: /system/user"
/>
<pro-input
required
title="组件路径"
tooltip="页面组件的文件路径"
path="component"
class="col-span-2"
placeholder="Eg: /system/user/index.vue"
/>
<pro-digit
title="排序"
tooltip="数字越小,同级中越靠前"
path="sort"
/>
<pro-input
title="权限标识"
tooltip="后端装饰器一致,如@RequirePermissions('system:user:list')"
path="perms"
placeholder="Eg: system:user:list"
/>
<pro-switch
title="启用"
path="status"
:field-props="{
checkedValue: 0,
uncheckedValue: 1,
}"
/>
<pro-switch
title="菜单可见"
path="menuVisible"
/>
<pro-switch
title="标签栏可见"
path="tabVisible"
/>
<pro-switch
title="页面缓存"
tooltip="开启配置后,切换页面数据不会清空"
path="keepAlive"
/>
<pro-switch
title="常驻标签栏"
path="pinTab"
/>
<pro-switch
title="外链菜单"
tooltip="开启配置后,点击菜单会跳转到外链地址"
path="isLink"
/>
</template>

View File

@ -1,22 +0,0 @@
<template>
<pro-input
required
title="权限标识"
tooltip="后端装饰器一致,如@RequirePermissions('system:user:add')"
path="perms"
placeholder="Eg: system:user:add"
/>
<pro-digit
title="排序"
tooltip="数字越小,同级中越靠前"
path="sort"
/>
<pro-switch
title="启用"
path="status"
:field-props="{
checkedValue: 0,
uncheckedValue: 1,
}"
/>
</template>

View File

@ -26,5 +26,10 @@
"isolatedModules": true,
"skipLibCheck": true
},
"include": [
"./**/*.ts",
"./**/*.tsx",
"./**/*.vue"
],
"exclude": ["node_modules", "eslint.config.js"]
}