From 5b9a418600c00624d29d9bcbf0d5b309b846d6d3 Mon Sep 17 00:00:00 2001 From: yuyang Date: Mon, 16 Jan 2023 18:54:21 +0800 Subject: [PATCH] feat: add backend dynamic router permission code (#394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 大致雏形 * feat: complete permission code * feat: 修复渲染问题并完善权限代码 * chore: release 0.7.0 * chore: 更新依赖 * chore: fix echarts version * feat: upgrade dependencies * chore: mock route map * chore: hash router * chore: concat fixed routers * chore: fix dependencies * feat: optimize * chore: remove roles from get menu process * chore: remain fe permssion code Co-authored-by: 悠静萝莉 --- .env | 2 +- .env.site | 2 +- mock/index.ts | 170 ++++++++++++++++++++ package.json | 75 ++++----- src/api/model/permissionModel.ts | 29 ++++ src/api/permission.ts | 12 ++ src/constants/index.ts | 7 + src/layouts/components/MenuContent.vue | 46 ++---- src/main.ts | 1 - src/permission.ts | 58 ++++--- src/router/index.ts | 48 +++--- src/router/modules/components.ts | 151 ----------------- src/router/modules/{base.ts => homepage.ts} | 17 +- src/router/modules/iframe.ts | 47 ------ src/router/modules/result.ts | 61 +++++++ src/router/modules/{others.ts => user.ts} | 5 +- src/store/modules/permission-fe.ts | 69 ++++++++ src/store/modules/permission.ts | 68 +++----- src/store/modules/user.ts | 17 +- src/utils/request/Axios.ts | 8 +- src/utils/request/index.ts | 5 +- src/utils/route/constant.ts | 14 ++ src/utils/route/index.ts | 107 ++++++++++++ vite.config.ts | 4 +- 24 files changed, 640 insertions(+), 383 deletions(-) create mode 100644 src/api/model/permissionModel.ts create mode 100644 src/api/permission.ts delete mode 100644 src/router/modules/components.ts rename src/router/modules/{base.ts => homepage.ts} (60%) delete mode 100644 src/router/modules/iframe.ts create mode 100644 src/router/modules/result.ts rename src/router/modules/{others.ts => user.ts} (82%) create mode 100644 src/store/modules/permission-fe.ts create mode 100644 src/utils/route/constant.ts create mode 100644 src/utils/route/index.ts diff --git a/.env b/.env index 1b6a998..14498f0 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ # 打包路径 根据项目不同按需配置 -VITE_BASE_URL = / \ No newline at end of file +VITE_BASE_URL = / diff --git a/.env.site b/.env.site index 0050436..6011a5c 100644 --- a/.env.site +++ b/.env.site @@ -1,2 +1,2 @@ # 打包路径 根据项目不同按需配置 -VITE_BASE_URL = https://static.tdesign.tencent.com/starter/vue-next/ \ No newline at end of file +VITE_BASE_URL = https://static.tdesign.tencent.com/starter/vue-next/ diff --git a/mock/index.ts b/mock/index.ts index 4f2730a..a02319d 100644 --- a/mock/index.ts +++ b/mock/index.ts @@ -144,4 +144,174 @@ export default [ }, }, }, + { + url: '/api/get-menu-list', + method: 'get', + timeout: 2000, + response: { + code: 0, + data: { + ...Mock.mock({ + list: [ + { + path: '/list', + name: 'list', + component: 'LAYOUT', + redirect: '/list/base', + meta: { + title: '列表页', + icon: 'view-list', + }, + children: [ + { + path: 'base', + name: 'ListBase', + component: '/list/base/index', + meta: { + title: '基础列表页', + }, + }, + { + path: 'card', + name: 'ListCard', + component: '/list/card/index', + meta: { + title: '卡片列表页', + }, + }, + { + path: 'filter', + name: 'ListFilter', + component: '/list/filter/index', + meta: { + title: '筛选列表页', + }, + }, + { + path: 'tree', + name: 'ListTree', + component: '/list/tree/index', + meta: { + title: '树状筛选列表页', + }, + }, + ], + }, + { + path: '/form', + name: 'form', + component: 'LAYOUT', + redirect: '/form/base', + meta: { + title: '表单页', + icon: 'edit-1', + }, + children: [ + { + path: 'base', + name: 'FormBase', + component: '/form/base/index', + meta: { + title: '基础表单页', + }, + }, + { + path: 'step', + name: 'FormStep', + component: '/form/step/index', + meta: { + title: '分步表单页', + }, + }, + ], + }, + { + path: '/detail', + name: 'detail', + component: 'LAYOUT', + redirect: '/detail/base', + meta: { + title: '详情页', + icon: 'layers', + }, + children: [ + { + path: 'base', + name: 'DetailBase', + component: '/detail/base/index', + meta: { + title: '基础详情页', + }, + }, + { + path: 'advanced', + name: 'DetailAdvanced', + component: '/detail/advanced/index', + meta: { + title: '多卡片详情页', + }, + }, + { + path: 'deploy', + name: 'DetailDeploy', + component: '/detail/deploy/index', + meta: { + title: '数据详情页', + }, + }, + { + path: 'secondary', + name: 'DetailSecondary', + component: '/detail/secondary/index', + meta: { + title: '二级详情页', + }, + }, + ], + }, + { + path: '/frame', + name: 'Frame', + component: 'Layout', + redirect: '/frame/doc', + meta: { + icon: 'internet', + title: '外部页面', + }, + children: [ + { + path: 'doc', + name: 'Doc', + component: 'IFrame', + meta: { + frameSrc: 'https://tdesign.tencent.com/starter/docs/vue-next/get-started', + title: '使用文档(内嵌)', + }, + }, + { + path: 'TDesign', + name: 'TDesign', + component: 'IFrame', + meta: { + frameSrc: 'https://tdesign.tencent.com/vue-next/getting-started', + title: 'TDesign 文档(内嵌)', + }, + }, + { + path: 'TDesign2', + name: 'TDesign2', + component: 'IFrame', + meta: { + frameSrc: 'https://tdesign.tencent.com/vue-next/getting-started', + frameBlank: true, + title: 'TDesign 文档(外链)', + }, + }, + ], + }, + ], + }), + }, + }, + }, ] as MockMethod[]; diff --git a/package.json b/package.json index a9fb3ad..22b20a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tdesign-vue-next-starter", - "version": "0.6.1", + "version": "0.7.0", "scripts": { "dev:mock": "vite --open --mode mock", "dev": "vite --open --mode development", @@ -19,58 +19,59 @@ "test:coverage": "echo \"no test:coverage specified,work in process\"" }, "dependencies": { - "axios": "^1.1.3", - "dayjs": "^1.10.6", - "echarts": "~5.1.2", + "@types/nprogress": "^0.2.0", + "axios": "^1.2.2", + "dayjs": "^1.11.7", + "echarts": "5.1.2", "lodash": "^4.17.21", "nprogress": "^0.2.0", - "pinia": "^2.0.11", - "pinia-plugin-persistedstate": "^3.0.1", - "qrcode.vue": "^3.2.2", - "qs": "^6.10.5", + "pinia": "^2.0.28", + "pinia-plugin-persistedstate": "^3.0.2", + "qrcode.vue": "^3.3.3", + "qs": "^6.11.0", "tdesign-icons-vue-next": "^0.1.7", "tdesign-vue-next": "^1.0.0", "tvision-color": "^1.5.0", - "vue": "^3.2.31", + "vue": "^3.2.45", "vue-clipboard3": "^2.0.0", - "vue-router": "~4.1.5" + "vue-router": "~4.1.6" }, "devDependencies": { - "@commitlint/cli": "^17.0.3", - "@commitlint/config-conventional": "^17.0.3", - "@types/echarts": "^4.9.10", - "@types/lodash": "^4.14.182", + "@commitlint/cli": "^17.3.0", + "@commitlint/config-conventional": "^17.3.0", + "@types/echarts": "^4.9.16", + "@types/lodash": "^4.14.191", "@types/qs": "^6.9.7", - "@types/ws": "^8.2.2", - "@typescript-eslint/eslint-plugin": "^4.29.3", - "@typescript-eslint/parser": "^4.29.3", - "@vitejs/plugin-vue": "^2.3.1", + "@types/ws": "^8.5.3", + "@typescript-eslint/eslint-plugin": "^5.47.1", + "@typescript-eslint/parser": "^5.47.1", + "@vitejs/plugin-vue": "^3.2.0", "@vitejs/plugin-vue-jsx": "^1.1.7", - "@vue/compiler-sfc": "^3.0.5", - "@vue/eslint-config-typescript": "^11.0.0", + "@vue/compiler-sfc": "^3.2.45", + "@vue/eslint-config-typescript": "^11.0.2", "commitizen": "^4.2.4", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.32.0", + "eslint": "^8.30.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-import": "^2.24.2", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-vue": "^9.2.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-vue": "^9.8.0", "eslint-plugin-vue-scoped-css": "^2.2.0", - "husky": "^7.0.4", - "less": "^4.1.1", - "lint-staged": "^12.1.2", + "husky": "^8.0.2", + "less": "^4.1.3", + "lint-staged": "^13.1.0", "mockjs": "^1.1.0", - "prettier": "^2.4.1", - "stylelint": "~13.13.1", - "stylelint-config-prettier": "~9.0.3", - "stylelint-less": "1.0.1", - "stylelint-order": "~4.1.0", - "typescript": "~4.8.4", - "vite": "^2.7.1", + "prettier": "^2.8.1", + "stylelint": "~14.9.1", + "stylelint-config-prettier": "~9.0.4", + "stylelint-less": "1.0.6", + "stylelint-order": "~6.0.1", + "typescript": "~4.9.4", + "vite": "^3.0.3", "vite-plugin-mock": "^2.9.6", - "vite-svg-loader": "^3.1.0", - "vue-tsc": "^1.0.8" + "vite-svg-loader": "^4.0.0", + "vue-tsc": "^1.0.19" }, "config": { "commitizen": { diff --git a/src/api/model/permissionModel.ts b/src/api/model/permissionModel.ts new file mode 100644 index 0000000..b16986f --- /dev/null +++ b/src/api/model/permissionModel.ts @@ -0,0 +1,29 @@ +import { defineComponent } from 'vue'; + +export interface MenuListResult { + list: Array; +} + +export type Component = + | ReturnType + | (() => Promise) + | (() => Promise); + +export interface RouteItem { + path: string; + name: string; + component?: Component | string; + components?: Component; + redirect?: string; + meta: RouteMeta; + children?: Array; +} +export interface RouteMeta { + title: string; + icon?: string; + expanded?: boolean; + orderNo?: number; + hidden?: boolean; + hiddenBreadcrumb?: boolean; + single?: boolean; +} diff --git a/src/api/permission.ts b/src/api/permission.ts new file mode 100644 index 0000000..cb9f1fc --- /dev/null +++ b/src/api/permission.ts @@ -0,0 +1,12 @@ +import { request } from '@/utils/request'; +import type { MenuListResult } from '@/api/model/permissionModel'; + +const Api = { + MenuList: '/get-menu-list', +}; + +export function getMenuList() { + return request.get({ + url: Api.MenuList, + }); +} diff --git a/src/constants/index.ts b/src/constants/index.ts index 105dca5..7c822ce 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -40,3 +40,10 @@ export const NOTIFICATION_TYPES = { middle: 'warning', high: 'danger', }; + +// 通用请求头 +export enum ContentTypeEnum { + Json = 'application/json;charset=UTF-8', + FormURLEncoded = 'application/x-www-form-urlencoded;charset=UTF-8', + FormData = 'multipart/form-data;charset=UTF-8', +} diff --git a/src/layouts/components/MenuContent.vue b/src/layouts/components/MenuContent.vue index b288547..0c451db 100644 --- a/src/layouts/components/MenuContent.vue +++ b/src/layouts/components/MenuContent.vue @@ -4,37 +4,34 @@ - - - - diff --git a/src/main.ts b/src/main.ts index 5110058..d11527f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,6 @@ import { createApp } from 'vue'; import TDesign from 'tdesign-vue-next'; import 'tdesign-vue-next/es/style/index.css'; - import { store } from './store'; import router from './router'; import '@/style/index.less'; diff --git a/src/permission.ts b/src/permission.ts index 97f922f..51d819a 100644 --- a/src/permission.ts +++ b/src/permission.ts @@ -2,18 +2,20 @@ import { MessagePlugin } from 'tdesign-vue-next'; import NProgress from 'nprogress'; // progress bar import 'nprogress/nprogress.css'; // progress bar style +import { RouteRecordRaw } from 'vue-router'; import { getPermissionStore, getUserStore } from '@/store'; import router from '@/router'; +import { PAGE_NOT_FOUND_ROUTE } from '@/utils/route/constant'; NProgress.configure({ showSpinner: false }); router.beforeEach(async (to, from, next) => { NProgress.start(); - const userStore = getUserStore(); const permissionStore = getPermissionStore(); const { whiteListRouters } = permissionStore; + const userStore = getUserStore(); const { token } = userStore; if (token) { if (to.path === '/login') { @@ -21,32 +23,40 @@ router.beforeEach(async (to, from, next) => { return; } - const { roles } = userStore; + const { asyncRoutes } = permissionStore; - if (roles && roles.length > 0) { - next(); - } else { - try { - await userStore.getUserInfo(); + if (asyncRoutes && asyncRoutes.length === 0) { + const routeList = await permissionStore.buildAsyncRoutes(); + routeList.forEach((item: RouteRecordRaw) => { + router.addRoute(item); + }); - const { roles } = userStore; - - await permissionStore.initRoutes(roles); - - if (router.hasRoute(to.name)) { - next(); - } else { - next(`/`); - } - } catch (error) { - MessagePlugin.error(error); - next({ - path: '/login', - query: { redirect: encodeURIComponent(to.fullPath) }, - }); - NProgress.done(); + if (to.name === PAGE_NOT_FOUND_ROUTE.name) { + // 动态添加路由后,此处应当重定向到fullPath,否则会加载404页面内容 + next({ path: to.fullPath, replace: true, query: to.query }); + } else { + const redirect = decodeURIComponent((from.query.redirect || to.path) as string); + next(to.path === redirect ? { ...to, replace: true } : { path: redirect }); + return; } } + + try { + await userStore.getUserInfo(); + + if (router.hasRoute(to.name)) { + next(); + } else { + next(`/`); + } + } catch (error) { + MessagePlugin.error(error); + next({ + path: '/login', + query: { redirect: encodeURIComponent(to.fullPath) }, + }); + NProgress.done(); + } } else { /* white list router */ if (whiteListRouters.indexOf(to.path) !== -1) { @@ -67,7 +77,7 @@ router.afterEach((to) => { const permissionStore = getPermissionStore(); userStore.logout(); - permissionStore.restore(); + permissionStore.restoreRoutes(); } NProgress.done(); }); diff --git a/src/router/index.ts b/src/router/index.ts index 4d26430..2ec2bcb 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,27 +1,15 @@ -import { useRoute, createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; +import { useRoute, createRouter, RouteRecordRaw, createWebHistory } from 'vue-router'; import uniq from 'lodash/uniq'; const env = import.meta.env.MODE || 'development'; -// 自动导入modules文件夹下所有ts文件 -const modules = import.meta.globEager('./modules/**/*.ts'); +// 导入homepage相关固定路由 +const homepageModules = import.meta.globEager('./modules/**/homepage.ts'); -// 路由暂存 -const routeModuleList: Array = []; +// 导入modules非homepage相关固定路由 +const fixedModules = import.meta.globEager('./modules/**/!(homepage).ts'); -Object.keys(modules).forEach((key) => { - // @ts-ignore - const mod = modules[key].default || {}; - const modList = Array.isArray(mod) ? [...mod] : [mod]; - routeModuleList.push(...modList); -}); - -// 关于单层路由,meta 中设置 { single: true } 即可为单层路由,{ hidden: true } 即可在侧边栏隐藏该路由 - -// 存放动态路由 -export const asyncRouterList: Array = [...routeModuleList]; - -// 存放固定的路由 +// 其他固定路由 const defaultRouterList: Array = [ { path: '/login', @@ -32,19 +20,29 @@ const defaultRouterList: Array = [ path: '/', redirect: '/dashboard/base', }, - { - path: '/:w+', - name: '404Page', - redirect: '/result/404', - }, ]; +// 存放固定路由 +export const homepageRouterList: Array = mapModuleRouterList(homepageModules); +export const fixedRouterList: Array = mapModuleRouterList(fixedModules); -export const allRoutes = [...defaultRouterList, ...asyncRouterList]; +export const allRoutes = [...homepageRouterList, ...fixedRouterList, ...defaultRouterList]; + +// 固定路由模块转换为路由 +export function mapModuleRouterList(modules: Record): Array { + const routerList: Array = []; + Object.keys(modules).forEach((key) => { + // @ts-ignore + const mod = modules[key].default || {}; + const modList = Array.isArray(mod) ? [...mod] : [mod]; + routerList.push(...modList); + }); + return routerList; +} export const getRoutesExpanded = () => { const expandedRoutes = []; - allRoutes.forEach((item) => { + fixedRouterList.forEach((item) => { if (item.meta && item.meta.expanded) { expandedRoutes.push(item.path); } diff --git a/src/router/modules/components.ts b/src/router/modules/components.ts deleted file mode 100644 index ba98689..0000000 --- a/src/router/modules/components.ts +++ /dev/null @@ -1,151 +0,0 @@ -import Layout from '@/layouts/index.vue'; -import ListIcon from '@/assets/assets-slide-list.svg'; -import FormIcon from '@/assets/assets-slide-form.svg'; -import DetailIcon from '@/assets/assets-slide-detail.svg'; - -export default [ - { - path: '/list', - name: 'list', - component: Layout, - redirect: '/list/base', - meta: { title: '列表页', icon: ListIcon }, - children: [ - { - path: 'base', - name: 'ListBase', - component: () => import('@/pages/list/base/index.vue'), - meta: { title: '基础列表页' }, - }, - { - path: 'card', - name: 'ListCard', - component: () => import('@/pages/list/card/index.vue'), - meta: { title: '卡片列表页' }, - }, - { - path: 'filter', - name: 'ListFilter', - component: () => import('@/pages/list/filter/index.vue'), - meta: { title: '筛选列表页' }, - }, - { - path: 'tree', - name: 'ListTree', - component: () => import('@/pages/list/tree/index.vue'), - meta: { title: '树状筛选列表页' }, - }, - ], - }, - { - path: '/form', - name: 'form', - component: Layout, - redirect: '/form/base', - meta: { title: '表单页', icon: FormIcon }, - children: [ - { - path: 'base', - name: 'FormBase', - component: () => import('@/pages/form/base/index.vue'), - meta: { title: '基础表单页' }, - }, - { - path: 'step', - name: 'FormStep', - component: () => import('@/pages/form/step/index.vue'), - meta: { title: '分步表单页' }, - }, - ], - }, - { - path: '/detail', - name: 'detail', - component: Layout, - redirect: '/detail/base', - meta: { title: '详情页', icon: DetailIcon }, - children: [ - { - path: 'base', - name: 'DetailBase', - component: () => import('@/pages/detail/base/index.vue'), - meta: { title: '基础详情页' }, - }, - { - path: 'advanced', - name: 'DetailAdvanced', - component: () => import('@/pages/detail/advanced/index.vue'), - meta: { title: '多卡片详情页' }, - }, - { - path: 'deploy', - name: 'DetailDeploy', - component: () => import('@/pages/detail/deploy/index.vue'), - meta: { title: '数据详情页' }, - }, - { - path: 'secondary', - name: 'DetailSecondary', - component: () => import('@/pages/detail/secondary/index.vue'), - meta: { title: '二级详情页' }, - }, - ], - }, - { - path: '/result', - name: 'result', - component: Layout, - redirect: '/result/success', - meta: { title: '结果页', icon: 'check-circle' }, - children: [ - { - path: 'success', - name: 'ResultSuccess', - component: () => import('@/pages/result/success/index.vue'), - meta: { title: '成功页' }, - }, - { - path: 'fail', - name: 'ResultFail', - component: () => import('@/pages/result/fail/index.vue'), - meta: { title: '失败页' }, - }, - { - path: 'network-error', - name: 'ResultNetworkError', - component: () => import('@/pages/result/network-error/index.vue'), - meta: { title: '网络异常' }, - }, - { - path: '403', - name: 'Result403', - component: () => import('@/pages/result/403/index.vue'), - meta: { title: '无权限' }, - }, - { - path: '404', - name: 'Result404', - component: () => import('@/pages/result/404/index.vue'), - meta: { title: '访问页面不存在页' }, - }, - { - path: '500', - name: 'Result500', - component: () => import('@/pages/result/500/index.vue'), - meta: { title: '服务器出错页' }, - }, - { - path: 'browser-incompatible', - name: 'ResultBrowserIncompatible', - component: () => import('@/pages/result/browser-incompatible/index.vue'), - meta: { title: '浏览器不兼容页' }, - }, - { - path: 'maintenance', - name: 'ResultMaintenance', - component: () => import('@/pages/result/maintenance/index.vue'), - meta: { title: '系统维护页' }, - }, - ], - }, -]; diff --git a/src/router/modules/base.ts b/src/router/modules/homepage.ts similarity index 60% rename from src/router/modules/base.ts rename to src/router/modules/homepage.ts index 000d455..0699fab 100644 --- a/src/router/modules/base.ts +++ b/src/router/modules/homepage.ts @@ -1,5 +1,6 @@ +import { DashboardIcon } from 'tdesign-icons-vue-next'; +import { shallowRef } from 'vue'; import Layout from '@/layouts/index.vue'; -import DashboardIcon from '@/assets/assets-slide-dashboard.svg'; export default [ { @@ -7,19 +8,27 @@ export default [ component: Layout, redirect: '/dashboard/base', name: 'dashboard', - meta: { title: '仪表盘', icon: DashboardIcon }, + meta: { + title: '仪表盘', + icon: shallowRef(DashboardIcon), + orderNo: 0, + }, children: [ { path: 'base', name: 'DashboardBase', component: () => import('@/pages/dashboard/base/index.vue'), - meta: { title: '概览仪表盘' }, + meta: { + title: '概览仪表盘', + }, }, { path: 'detail', name: 'DashboardDetail', component: () => import('@/pages/dashboard/detail/index.vue'), - meta: { title: '统计报表' }, + meta: { + title: '统计报表', + }, }, ], }, diff --git a/src/router/modules/iframe.ts b/src/router/modules/iframe.ts deleted file mode 100644 index 58b0393..0000000 --- a/src/router/modules/iframe.ts +++ /dev/null @@ -1,47 +0,0 @@ -import Layout from '@/layouts/index.vue'; - -const IFrame = () => import('@/layouts/components/FrameBlank.vue'); - -export default [ - { - path: '/frame', - name: 'Frame', - component: Layout, - redirect: '/frame/doc', - meta: { - icon: 'internet', - title: '外部页面', - }, - - children: [ - { - path: 'doc', - name: 'Doc', - component: IFrame, - meta: { - frameSrc: 'https://tdesign.tencent.com/starter/docs/vue-next/get-started', - title: '使用文档(内嵌)', - }, - }, - { - path: 'TDesign', - name: 'TDesign', - component: IFrame, - meta: { - frameSrc: 'https://tdesign.tencent.com/vue-next/getting-started', - title: 'TDesign 文档(内嵌)', - }, - }, - { - path: 'TDesign2', - name: 'TDesign2', - component: IFrame, - meta: { - frameSrc: 'https://tdesign.tencent.com/vue-next/getting-started', - frameBlank: true, - title: 'TDesign 文档(外链)', - }, - }, - ], - }, -]; diff --git a/src/router/modules/result.ts b/src/router/modules/result.ts new file mode 100644 index 0000000..07506f2 --- /dev/null +++ b/src/router/modules/result.ts @@ -0,0 +1,61 @@ +import Layout from '@/layouts/index.vue'; + +export default [ + { + path: '/result', + name: 'result', + component: Layout, + redirect: '/result/success', + meta: { title: '结果页', icon: 'check-circle' }, + children: [ + { + path: 'success', + name: 'ResultSuccess', + component: () => import('@/pages/result/success/index.vue'), + meta: { title: '成功页' }, + }, + { + path: 'fail', + name: 'ResultFail', + component: () => import('@/pages/result/fail/index.vue'), + meta: { title: '失败页' }, + }, + { + path: 'network-error', + name: 'ResultNetworkError', + component: () => import('@/pages/result/network-error/index.vue'), + meta: { title: '网络异常' }, + }, + { + path: '403', + name: 'Result403', + component: () => import('@/pages/result/403/index.vue'), + meta: { title: '无权限' }, + }, + { + path: '404', + name: 'Result404', + component: () => import('@/pages/result/404/index.vue'), + meta: { title: '访问页面不存在页' }, + }, + { + path: '500', + name: 'Result500', + component: () => import('@/pages/result/500/index.vue'), + meta: { title: '服务器出错页' }, + }, + { + path: 'browser-incompatible', + name: 'ResultBrowserIncompatible', + component: () => import('@/pages/result/browser-incompatible/index.vue'), + meta: { title: '浏览器不兼容页' }, + }, + { + path: 'maintenance', + name: 'ResultMaintenance', + component: () => import('@/pages/result/maintenance/index.vue'), + meta: { title: '系统维护页' }, + }, + ], + }, +]; diff --git a/src/router/modules/others.ts b/src/router/modules/user.ts similarity index 82% rename from src/router/modules/others.ts rename to src/router/modules/user.ts index 974bfb7..5f9844f 100644 --- a/src/router/modules/others.ts +++ b/src/router/modules/user.ts @@ -1,5 +1,6 @@ +import { LogoutIcon } from 'tdesign-icons-vue-next'; +import { shallowRef } from 'vue'; import Layout from '@/layouts/index.vue'; -import LogoutIcon from '@/assets/assets-slide-logout.svg'; export default [ { @@ -21,7 +22,7 @@ export default [ path: '/loginRedirect', name: 'loginRedirect', redirect: '/login', - meta: { title: '登录页', icon: LogoutIcon }, + meta: { title: '登录页', icon: shallowRef(LogoutIcon) }, component: () => import('@/layouts/blank.vue'), children: [ { diff --git a/src/store/modules/permission-fe.ts b/src/store/modules/permission-fe.ts new file mode 100644 index 0000000..de19617 --- /dev/null +++ b/src/store/modules/permission-fe.ts @@ -0,0 +1,69 @@ +// 前端 roles 控制菜单权限 通过登录后的角色对菜单就行过滤处理 +// 如果需要前端 roles 控制菜单权限 请使用此文件代码替换 permission.ts 的内容 + +import { defineStore } from 'pinia'; +import { RouteRecordRaw } from 'vue-router'; +import router, { allRoutes } from '@/router'; +import { store } from '@/store'; + +function filterPermissionsRouters(routes: Array, roles: Array) { + const res = []; + const removeRoutes = []; + routes.forEach((route) => { + const children = []; + route.children?.forEach((childRouter) => { + const roleCode = childRouter.meta?.roleCode || childRouter.name; + if (roles.indexOf(roleCode) !== -1) { + children.push(childRouter); + } else { + removeRoutes.push(childRouter); + } + }); + if (children.length > 0) { + route.children = children; + res.push(route); + } + }); + return { accessedRouters: res, removeRoutes }; +} + +export const usePermissionStore = defineStore('permission', { + state: () => ({ + whiteListRouters: ['/login'], + routers: [], + removeRoutes: [], + }), + actions: { + async initRoutes(roles: Array) { + let accessedRouters = []; + + let removeRoutes = []; + // special token + if (roles.includes('all')) { + accessedRouters = allRoutes; + } else { + const res = filterPermissionsRouters(allRoutes, roles); + accessedRouters = res.accessedRouters; + removeRoutes = res.removeRoutes; + } + + this.routers = accessedRouters; + this.removeRoutes = removeRoutes; + + removeRoutes.forEach((item: RouteRecordRaw) => { + if (router.hasRoute(item.name)) { + router.removeRoute(item.name); + } + }); + }, + async restore() { + this.removeRoutes.forEach((item: RouteRecordRaw) => { + router.addRoute(item); + }); + }, + }, +}); + +export function getPermissionStore() { + return usePermissionStore(store); +} diff --git a/src/store/modules/permission.ts b/src/store/modules/permission.ts index 823d409..37a2008 100644 --- a/src/store/modules/permission.ts +++ b/src/store/modules/permission.ts @@ -1,59 +1,41 @@ import { defineStore } from 'pinia'; import { RouteRecordRaw } from 'vue-router'; -import router, { asyncRouterList } from '@/router'; +import router, { fixedRouterList, homepageRouterList } from '@/router'; import { store } from '@/store'; - -function filterPermissionsRouters(routes: Array, roles: Array) { - const res = []; - const removeRoutes = []; - routes.forEach((route) => { - const children = []; - route.children?.forEach((childRouter) => { - const roleCode = childRouter.meta?.roleCode || childRouter.name; - if (roles.indexOf(roleCode) !== -1) { - children.push(childRouter); - } else { - removeRoutes.push(childRouter); - } - }); - if (children.length > 0) { - route.children = children; - res.push(route); - } - }); - return { accessedRouters: res, removeRoutes }; -} +import { RouteItem } from '@/api/model/permissionModel'; +import { getMenuList } from '@/api/permission'; +import { transformObjectToRoute } from '@/utils/route'; export const usePermissionStore = defineStore('permission', { state: () => ({ whiteListRouters: ['/login'], routers: [], removeRoutes: [], + asyncRoutes: [], }), actions: { - async initRoutes(roles: Array) { - let accessedRouters = []; + async initRoutes() { + const accessedRouters = this.asyncRoutes; - let removeRoutes = []; - // special token - if (roles.includes('all')) { - accessedRouters = asyncRouterList; - } else { - const res = filterPermissionsRouters(asyncRouterList, roles); - accessedRouters = res.accessedRouters; - removeRoutes = res.removeRoutes; - } - - this.routers = accessedRouters; - this.removeRoutes = removeRoutes; - - removeRoutes.forEach((item: RouteRecordRaw) => { - if (router.hasRoute(item.name)) { - router.removeRoute(item.name); - } - }); + // 在菜单展示全部路由 + this.routers = [...homepageRouterList, ...accessedRouters, ...fixedRouterList]; + // 在菜单只展示动态路由和首页 + // this.routers = [...homepageRouterList, ...accessedRouters]; + // 在菜单只展示动态路由 + // this.routers = [...accessedRouters]; }, - async restore() { + async buildAsyncRoutes() { + try { + // 发起菜单权限请求 获取菜单列表 + const asyncRoutes: Array = (await getMenuList()).list; + this.asyncRoutes = transformObjectToRoute(asyncRoutes); + await this.initRoutes(); + return this.asyncRoutes; + } catch (error) { + throw new Error("Can't build routes"); + } + }, + async restoreRoutes() { this.removeRoutes.forEach((item: RouteRecordRaw) => { router.addRoute(item); }); diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts index a69c94c..bf07cbb 100644 --- a/src/store/modules/user.ts +++ b/src/store/modules/user.ts @@ -3,7 +3,7 @@ import { TOKEN_NAME } from '@/config/global'; import { store, usePermissionStore } from '@/store'; const InitUserInfo = { - roles: [], + roles: [], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用 }; export const useUserStore = defineStore('user', { @@ -20,7 +20,7 @@ export const useUserStore = defineStore('user', { async login(userInfo: Record) { const mockLogin = async (userInfo: Record) => { // 登录请求流程 - console.log(userInfo); + console.log(`用户信息:`, userInfo); // const { account, password } = userInfo; // if (account !== 'td') { // return { @@ -57,15 +57,14 @@ export const useUserStore = defineStore('user', { if (token === 'main_token') { return { name: 'td_main', - roles: ['all'], + roles: ['all'], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用 }; } return { name: 'td_dev', - roles: ['UserIndex', 'DashboardBase', 'login'], + roles: ['UserIndex', 'DashboardBase', 'login'], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用 }; }; - const res = await mockRemoteUserInfo(this.token); this.userInfo = res; @@ -80,11 +79,9 @@ export const useUserStore = defineStore('user', { }, }, persist: { - afterRestore: (ctx) => { - if (ctx.store.roles && ctx.store.roles.length > 0) { - const permissionStore = usePermissionStore(); - permissionStore.initRoutes(ctx.store.roles); - } + afterRestore: () => { + const permissionStore = usePermissionStore(); + permissionStore.initRoutes(); }, }, }); diff --git a/src/utils/request/Axios.ts b/src/utils/request/Axios.ts index 0984b7a..416be09 100644 --- a/src/utils/request/Axios.ts +++ b/src/utils/request/Axios.ts @@ -5,6 +5,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { CreateAxiosOptions } from './AxiosTransform'; import { AxiosCanceler } from './AxiosCancel'; import { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/axios'; +import { ContentTypeEnum } from '@/constants'; // Axios模块 export class VAxios { @@ -64,12 +65,15 @@ export class VAxios { // 请求配置处理 this.instance.interceptors.request.use((config: AxiosRequestConfig) => { - const ignoreRepeat = this.options.requestOptions?.ignoreRepeatRequest; + // @ts-ignore + const { ignoreRepeatRequest } = config.requestOptions; + const ignoreRepeat = ignoreRepeatRequest ?? this.options.requestOptions?.ignoreRepeatRequest; if (!ignoreRepeat) axiosCanceler.addPending(config); if (requestInterceptors && isFunction(requestInterceptors)) { config = requestInterceptors(config, this.options); } + return config; }, undefined); @@ -99,7 +103,7 @@ export class VAxios { const contentType = headers?.['Content-Type'] || headers?.['content-type']; if ( - contentType !== 'application/x-www-form-urlencoded;charset=UTF-8' || + contentType !== ContentTypeEnum.FormURLEncoded || !Reflect.has(config, 'data') || config.method?.toUpperCase() === 'GET' ) { diff --git a/src/utils/request/index.ts b/src/utils/request/index.ts index 1ce5a72..823072b 100644 --- a/src/utils/request/index.ts +++ b/src/utils/request/index.ts @@ -6,6 +6,7 @@ import { VAxios } from './Axios'; import proxy from '@/config/proxy'; import { joinTimestamp, formatRequestDate, setObjToUrlParams } from './utils'; import { TOKEN_NAME } from '@/config/global'; +import { ContentTypeEnum } from '@/constants'; const env = import.meta.env.MODE || 'development'; @@ -141,7 +142,7 @@ const transform: AxiosTransform = { resolve(config); }, config.requestOptions.retry.delay || 1); }); - config.headers = { ...config.headers, 'Content-Type': 'application/json;charset=UTF-8' }; + config.headers = { ...config.headers, 'Content-Type': ContentTypeEnum.Json }; return backoff.then((config) => request.request(config)); }, }; @@ -158,7 +159,7 @@ function createAxios(opt?: Partial) { // 携带Cookie withCredentials: true, // 头信息 - headers: { 'Content-Type': 'application/json;charset=UTF-8' }, + headers: { 'Content-Type': ContentTypeEnum.Json }, // 数据处理方式 transform, // 配置项,下面的选项都可以在独立的接口请求中覆盖 diff --git a/src/utils/route/constant.ts b/src/utils/route/constant.ts new file mode 100644 index 0000000..a3ec01c --- /dev/null +++ b/src/utils/route/constant.ts @@ -0,0 +1,14 @@ +export const LAYOUT = () => import('@/layouts/index.vue'); +export const BLANK_LAYOUT = () => import('@/layouts/blank.vue'); +export const IFRAME = () => import('@/layouts/components/FrameBlank.vue'); +export const EXCEPTION_COMPONENT = () => import('@/pages/result/500/index.vue'); +export const PARENT_LAYOUT = () => + new Promise((resolve) => { + resolve({ name: 'ParentLayout' }); + }); + +export const PAGE_NOT_FOUND_ROUTE = { + path: '/:w+', + name: '404Page', + redirect: '/result/404', +}; diff --git a/src/utils/route/index.ts b/src/utils/route/index.ts new file mode 100644 index 0000000..dcf4ba3 --- /dev/null +++ b/src/utils/route/index.ts @@ -0,0 +1,107 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { shallowRef } from 'vue'; +import { RouteItem, RouteMeta } from '@/api/model/permissionModel'; +import { + BLANK_LAYOUT, + LAYOUT, + IFRAME, + EXCEPTION_COMPONENT, + PARENT_LAYOUT, + PAGE_NOT_FOUND_ROUTE, +} from '@/utils/route/constant'; + +// vite 3+ support dynamic import from node_modules +const iconsPath = import.meta.glob('../../../node_modules/tdesign-icons-vue-next/esm/components/*.js'); + +const LayoutMap = new Map Promise>(); + +LayoutMap.set('LAYOUT', LAYOUT); +LayoutMap.set('BLANK', BLANK_LAYOUT); +LayoutMap.set('IFRAME', IFRAME); + +let dynamicViewsModules: Record Promise>; + +// 动态从包内引入单个Icon +async function getMenuIcon(iconName: string) { + const RenderIcon = iconsPath[`../../../node_modules/tdesign-icons-vue-next/esm/components/${iconName}.js`]; + + const Icon = await RenderIcon(); + // @ts-ignore + return shallowRef(Icon.default); +} + +// 动态引入路由组件 +function asyncImportRoute(routes: RouteItem[] | undefined) { + dynamicViewsModules = dynamicViewsModules || import.meta.glob('../../pages/**/*.vue'); + if (!routes) return; + + routes.forEach(async (item) => { + const { component, name } = item; + const { children } = item; + + if (component) { + const layoutFound = LayoutMap.get(component.toUpperCase()); + if (layoutFound) { + item.component = layoutFound; + } else { + item.component = dynamicImport(dynamicViewsModules, component); + } + } else if (name) { + item.component = PARENT_LAYOUT(); + } + if (item.meta.icon) item.meta.icon = await getMenuIcon(item.meta.icon); + + // eslint-disable-next-line no-unused-expressions + children && asyncImportRoute(children); + }); +} + +function dynamicImport(dynamicViewsModules: Record Promise>, component: string) { + const keys = Object.keys(dynamicViewsModules); + const matchKeys = keys.filter((key) => { + const k = key.replace('../../pages', ''); + const startFlag = component.startsWith('/'); + const endFlag = component.endsWith('.vue') || component.endsWith('.tsx'); + const startIndex = startFlag ? 0 : 1; + const lastIndex = endFlag ? k.length : k.lastIndexOf('.'); + return k.substring(startIndex, lastIndex) === component; + }); + if (matchKeys?.length === 1) { + const matchKey = matchKeys[0]; + return dynamicViewsModules[matchKey]; + } + if (matchKeys?.length > 1) { + throw new Error( + 'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure', + ); + } else { + console.warn(`Can't find ${component} in pages folder`); + } + return EXCEPTION_COMPONENT; +} + +// 将背景对象变成路由对象 +export function transformObjectToRoute(routeList: RouteItem[]): T[] { + routeList.forEach(async (route) => { + const component = route.component as string; + + if (component) { + if (component.toUpperCase() === 'LAYOUT') { + route.component = LayoutMap.get(component.toUpperCase()); + } else { + route.children = [cloneDeep(route)]; + route.component = LAYOUT; + route.name = `${route.name}Parent`; + route.path = ''; + route.meta = (route.meta || {}) as RouteMeta; + } + } else { + throw new Error('component is undefined'); + } + // eslint-disable-next-line no-unused-expressions + route.children && asyncImportRoute(route.children); + if (route.meta.icon) route.meta.icon = await getMenuIcon(route.meta.icon); + }); + + return [PAGE_NOT_FOUND_ROUTE, ...routeList] as unknown as T[]; +} diff --git a/vite.config.ts b/vite.config.ts index 15d19e9..5bd2498 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ import { ConfigEnv, UserConfig, loadEnv } from 'vite'; import { viteMockServe } from 'vite-plugin-mock'; -import createVuePlugin from '@vitejs/plugin-vue'; +import vue from '@vitejs/plugin-vue'; import vueJsx from '@vitejs/plugin-vue-jsx'; import svgLoader from 'vite-svg-loader'; @@ -32,7 +32,7 @@ export default ({ mode }: ConfigEnv): UserConfig => { }, plugins: [ - createVuePlugin(), + vue(), vueJsx(), viteMockServe({ mockPath: 'mock',