feat: add backend dynamic router permission code (#394) (#397)

* 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: 悠静萝莉 <i@mikuhl.cn>

Co-authored-by: 悠静萝莉 <i@mikuhl.cn>
This commit is contained in:
yuyang 2023-01-16 22:51:19 +08:00 committed by GitHub
parent 26b0a1b350
commit 065540e66b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 640 additions and 383 deletions

2
.env
View File

@ -1,2 +1,2 @@
# 打包路径 根据项目不同按需配置 # 打包路径 根据项目不同按需配置
VITE_BASE_URL = / VITE_BASE_URL = /

View File

@ -1,2 +1,2 @@
# 打包路径 根据项目不同按需配置 # 打包路径 根据项目不同按需配置
VITE_BASE_URL = https://static.tdesign.tencent.com/starter/vue-next/ VITE_BASE_URL = https://static.tdesign.tencent.com/starter/vue-next/

View File

@ -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[]; ] as MockMethod[];

View File

@ -1,6 +1,6 @@
{ {
"name": "tdesign-vue-next-starter", "name": "tdesign-vue-next-starter",
"version": "0.6.1", "version": "0.7.0",
"scripts": { "scripts": {
"dev:mock": "vite --open --mode mock", "dev:mock": "vite --open --mode mock",
"dev": "vite --open --mode development", "dev": "vite --open --mode development",
@ -19,58 +19,59 @@
"test:coverage": "echo \"no test:coverage specified,work in process\"" "test:coverage": "echo \"no test:coverage specified,work in process\""
}, },
"dependencies": { "dependencies": {
"axios": "^1.1.3", "@types/nprogress": "^0.2.0",
"dayjs": "^1.10.6", "axios": "^1.2.2",
"echarts": "~5.1.2", "dayjs": "^1.11.7",
"echarts": "5.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pinia": "^2.0.11", "pinia": "^2.0.28",
"pinia-plugin-persistedstate": "^3.0.1", "pinia-plugin-persistedstate": "^3.0.2",
"qrcode.vue": "^3.2.2", "qrcode.vue": "^3.3.3",
"qs": "^6.10.5", "qs": "^6.11.0",
"tdesign-icons-vue-next": "^0.1.7", "tdesign-icons-vue-next": "^0.1.7",
"tdesign-vue-next": "^1.0.0", "tdesign-vue-next": "^1.0.0",
"tvision-color": "^1.5.0", "tvision-color": "^1.5.0",
"vue": "^3.2.31", "vue": "^3.2.45",
"vue-clipboard3": "^2.0.0", "vue-clipboard3": "^2.0.0",
"vue-router": "~4.1.5" "vue-router": "~4.1.6"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^17.0.3", "@commitlint/cli": "^17.3.0",
"@commitlint/config-conventional": "^17.0.3", "@commitlint/config-conventional": "^17.3.0",
"@types/echarts": "^4.9.10", "@types/echarts": "^4.9.16",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.191",
"@types/qs": "^6.9.7", "@types/qs": "^6.9.7",
"@types/ws": "^8.2.2", "@types/ws": "^8.5.3",
"@typescript-eslint/eslint-plugin": "^4.29.3", "@typescript-eslint/eslint-plugin": "^5.47.1",
"@typescript-eslint/parser": "^4.29.3", "@typescript-eslint/parser": "^5.47.1",
"@vitejs/plugin-vue": "^2.3.1", "@vitejs/plugin-vue": "^3.2.0",
"@vitejs/plugin-vue-jsx": "^1.1.7", "@vitejs/plugin-vue-jsx": "^1.1.7",
"@vue/compiler-sfc": "^3.0.5", "@vue/compiler-sfc": "^3.2.45",
"@vue/eslint-config-typescript": "^11.0.0", "@vue/eslint-config-typescript": "^11.0.2",
"commitizen": "^4.2.4", "commitizen": "^4.2.4",
"cz-conventional-changelog": "^3.3.0", "cz-conventional-changelog": "^3.3.0",
"eslint": "^7.32.0", "eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.24.2", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.2.0", "eslint-plugin-vue": "^9.8.0",
"eslint-plugin-vue-scoped-css": "^2.2.0", "eslint-plugin-vue-scoped-css": "^2.2.0",
"husky": "^7.0.4", "husky": "^8.0.2",
"less": "^4.1.1", "less": "^4.1.3",
"lint-staged": "^12.1.2", "lint-staged": "^13.1.0",
"mockjs": "^1.1.0", "mockjs": "^1.1.0",
"prettier": "^2.4.1", "prettier": "^2.8.1",
"stylelint": "~13.13.1", "stylelint": "~14.9.1",
"stylelint-config-prettier": "~9.0.3", "stylelint-config-prettier": "~9.0.4",
"stylelint-less": "1.0.1", "stylelint-less": "1.0.6",
"stylelint-order": "~4.1.0", "stylelint-order": "~6.0.1",
"typescript": "~4.8.4", "typescript": "~4.9.4",
"vite": "^2.7.1", "vite": "^3.0.3",
"vite-plugin-mock": "^2.9.6", "vite-plugin-mock": "^2.9.6",
"vite-svg-loader": "^3.1.0", "vite-svg-loader": "^4.0.0",
"vue-tsc": "^1.0.8" "vue-tsc": "^1.0.19"
}, },
"config": { "config": {
"commitizen": { "commitizen": {

View File

@ -0,0 +1,29 @@
import { defineComponent } from 'vue';
export interface MenuListResult {
list: Array<RouteItem>;
}
export type Component<T = any> =
| ReturnType<typeof defineComponent>
| (() => Promise<typeof import('*.vue')>)
| (() => Promise<T>);
export interface RouteItem {
path: string;
name: string;
component?: Component | string;
components?: Component;
redirect?: string;
meta: RouteMeta;
children?: Array<RouteItem>;
}
export interface RouteMeta {
title: string;
icon?: string;
expanded?: boolean;
orderNo?: number;
hidden?: boolean;
hiddenBreadcrumb?: boolean;
single?: boolean;
}

12
src/api/permission.ts Normal file
View File

@ -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<MenuListResult>({
url: Api.MenuList,
});
}

View File

@ -40,3 +40,10 @@ export const NOTIFICATION_TYPES = {
middle: 'warning', middle: 'warning',
high: 'danger', 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',
}

View File

@ -4,37 +4,34 @@
<template v-if="!item.children || !item.children.length || item.meta?.single"> <template v-if="!item.children || !item.children.length || item.meta?.single">
<t-menu-item v-if="getHref(item)" :name="item.path" :value="getPath(item)" @click="openHref(getHref(item)[0])"> <t-menu-item v-if="getHref(item)" :name="item.path" :value="getPath(item)" @click="openHref(getHref(item)[0])">
<template #icon> <template #icon>
<t-icon v-if="beIcon(item)" :name="item.icon" /> <component :is="menuIcon(item)"></component>
<component :is="beRender(item).render" v-else-if="beRender(item).can" class="t-icon" />
</template> </template>
{{ item.title }} {{ item.title }}
</t-menu-item> </t-menu-item>
<t-menu-item v-else :name="item.path" :value="getPath(item)" :to="item.path"> <t-menu-item v-else :name="item.path" :value="getPath(item)" :to="item.path">
<template #icon> <template #icon>
<t-icon v-if="beIcon(item)" :name="item.icon" /> <component :is="menuIcon(item)"></component>
<component :is="beRender(item).render" v-else-if="beRender(item).can" class="t-icon" />
</template> </template>
{{ item.title }} {{ item.title }}
</t-menu-item> </t-menu-item>
</template> </template>
<t-submenu v-else :name="item.path" :value="item.path" :title="item.title"> <t-submenu v-else :name="item.path" :value="item.path" :title="item.title">
<template #icon> <template #icon>
<t-icon v-if="beIcon(item)" :name="item.icon" /> <component :is="menuIcon(item)"></component>
<component :is="beRender(item).render" v-else-if="beRender(item).can" class="t-icon" />
</template> </template>
<menu-content v-if="item.children" :nav-data="item.children" /> <menu-content v-if="item.children" :nav-data="item.children" />
</t-submenu> </t-submenu>
</template> </template>
</div> </div>
</template> </template>
<script setup lang="tsx">
<script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import isObject from 'lodash/isObject';
import type { MenuRoute } from '@/types/interface'; import type { MenuRoute } from '@/types/interface';
import { getActive } from '@/router'; import { getActive } from '@/router';
type ListItemType = MenuRoute & { icon?: string };
const props = defineProps({ const props = defineProps({
navData: { navData: {
type: Array as PropType<MenuRoute[]>, type: Array as PropType<MenuRoute[]>,
@ -43,12 +40,17 @@ const props = defineProps({
}); });
const active = computed(() => getActive()); const active = computed(() => getActive());
const list = computed(() => { const list = computed(() => {
const { navData } = props; const { navData } = props;
return getMenuList(navData); return getMenuList(navData);
}); });
type ListItemType = MenuRoute & { icon?: string }; const menuIcon = (item: ListItemType) => {
if (typeof item.icon === 'string') return <t-icon name={item.icon} />;
const RenderIcon = item.icon;
return RenderIcon;
};
const getMenuList = (list: MenuRoute[], basePath?: string): ListItemType[] => { const getMenuList = (list: MenuRoute[], basePath?: string): ListItemType[] => {
if (!list) { if (!list) {
@ -61,10 +63,11 @@ const getMenuList = (list: MenuRoute[], basePath?: string): ListItemType[] => {
return list return list
.map((item) => { .map((item) => {
const path = basePath && !item.path.includes(basePath) ? `${basePath}/${item.path}` : item.path; const path = basePath && !item.path.includes(basePath) ? `${basePath}/${item.path}` : item.path;
return { return {
path, path,
title: item.meta?.title, title: item.meta?.title,
icon: item.meta?.icon || '', icon: item.meta?.icon,
children: getMenuList(item.children, path), children: getMenuList(item.children, path),
meta: item.meta, meta: item.meta,
redirect: item.redirect, redirect: item.redirect,
@ -81,33 +84,14 @@ const getHref = (item: MenuRoute) => {
return null; return null;
}; };
const getPath = (item) => { const getPath = (item: ListItemType) => {
if (active.value.startsWith(item.path)) { if (active.value.startsWith(item.path)) {
return active.value; return active.value;
} }
return item.meta?.single ? item.redirect : item.path; return item.meta?.single ? item.redirect : item.path;
}; };
const beIcon = (item: MenuRoute) => {
return item.icon && typeof item.icon === 'string';
};
const beRender = (item: MenuRoute) => {
if (isObject(item.icon) && typeof item.icon.render === 'function') {
return {
can: true,
render: item.icon.render,
};
}
return {
can: false,
render: null,
};
};
const openHref = (url: string) => { const openHref = (url: string) => {
window.open(url); window.open(url);
}; };
</script> </script>
<style lang="less" scoped></style>

View File

@ -2,7 +2,6 @@ import { createApp } from 'vue';
import TDesign from 'tdesign-vue-next'; import TDesign from 'tdesign-vue-next';
import 'tdesign-vue-next/es/style/index.css'; import 'tdesign-vue-next/es/style/index.css';
import { store } from './store'; import { store } from './store';
import router from './router'; import router from './router';
import '@/style/index.less'; import '@/style/index.less';

View File

@ -2,18 +2,20 @@ import { MessagePlugin } from 'tdesign-vue-next';
import NProgress from 'nprogress'; // progress bar import NProgress from 'nprogress'; // progress bar
import 'nprogress/nprogress.css'; // progress bar style import 'nprogress/nprogress.css'; // progress bar style
import { RouteRecordRaw } from 'vue-router';
import { getPermissionStore, getUserStore } from '@/store'; import { getPermissionStore, getUserStore } from '@/store';
import router from '@/router'; import router from '@/router';
import { PAGE_NOT_FOUND_ROUTE } from '@/utils/route/constant';
NProgress.configure({ showSpinner: false }); NProgress.configure({ showSpinner: false });
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
NProgress.start(); NProgress.start();
const userStore = getUserStore();
const permissionStore = getPermissionStore(); const permissionStore = getPermissionStore();
const { whiteListRouters } = permissionStore; const { whiteListRouters } = permissionStore;
const userStore = getUserStore();
const { token } = userStore; const { token } = userStore;
if (token) { if (token) {
if (to.path === '/login') { if (to.path === '/login') {
@ -21,32 +23,40 @@ router.beforeEach(async (to, from, next) => {
return; return;
} }
const { roles } = userStore; const { asyncRoutes } = permissionStore;
if (roles && roles.length > 0) { if (asyncRoutes && asyncRoutes.length === 0) {
next(); const routeList = await permissionStore.buildAsyncRoutes();
} else { routeList.forEach((item: RouteRecordRaw) => {
try { router.addRoute(item);
await userStore.getUserInfo(); });
const { roles } = userStore; if (to.name === PAGE_NOT_FOUND_ROUTE.name) {
// 动态添加路由后此处应当重定向到fullPath否则会加载404页面内容
await permissionStore.initRoutes(roles); next({ path: to.fullPath, replace: true, query: to.query });
} else {
if (router.hasRoute(to.name)) { const redirect = decodeURIComponent((from.query.redirect || to.path) as string);
next(); next(to.path === redirect ? { ...to, replace: true } : { path: redirect });
} else { return;
next(`/`);
}
} catch (error) {
MessagePlugin.error(error);
next({
path: '/login',
query: { redirect: encodeURIComponent(to.fullPath) },
});
NProgress.done();
} }
} }
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 { } else {
/* white list router */ /* white list router */
if (whiteListRouters.indexOf(to.path) !== -1) { if (whiteListRouters.indexOf(to.path) !== -1) {
@ -67,7 +77,7 @@ router.afterEach((to) => {
const permissionStore = getPermissionStore(); const permissionStore = getPermissionStore();
userStore.logout(); userStore.logout();
permissionStore.restore(); permissionStore.restoreRoutes();
} }
NProgress.done(); NProgress.done();
}); });

View File

@ -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'; import uniq from 'lodash/uniq';
const env = import.meta.env.MODE || 'development'; const env = import.meta.env.MODE || 'development';
// 自动导入modules文件夹下所有ts文件 // 导入homepage相关固定路由
const modules = import.meta.globEager('./modules/**/*.ts'); const homepageModules = import.meta.globEager('./modules/**/homepage.ts');
// 路由暂存 // 导入modules非homepage相关固定路由
const routeModuleList: Array<RouteRecordRaw> = []; 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<RouteRecordRaw> = [...routeModuleList];
// 存放固定的路由
const defaultRouterList: Array<RouteRecordRaw> = [ const defaultRouterList: Array<RouteRecordRaw> = [
{ {
path: '/login', path: '/login',
@ -32,19 +20,29 @@ const defaultRouterList: Array<RouteRecordRaw> = [
path: '/', path: '/',
redirect: '/dashboard/base', redirect: '/dashboard/base',
}, },
{
path: '/:w+',
name: '404Page',
redirect: '/result/404',
},
]; ];
// 存放固定路由
export const homepageRouterList: Array<RouteRecordRaw> = mapModuleRouterList(homepageModules);
export const fixedRouterList: Array<RouteRecordRaw> = mapModuleRouterList(fixedModules);
export const allRoutes = [...defaultRouterList, ...asyncRouterList]; export const allRoutes = [...homepageRouterList, ...fixedRouterList, ...defaultRouterList];
// 固定路由模块转换为路由
export function mapModuleRouterList(modules: Record<string, unknown>): Array<RouteRecordRaw> {
const routerList: Array<RouteRecordRaw> = [];
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 = () => { export const getRoutesExpanded = () => {
const expandedRoutes = []; const expandedRoutes = [];
allRoutes.forEach((item) => { fixedRouterList.forEach((item) => {
if (item.meta && item.meta.expanded) { if (item.meta && item.meta.expanded) {
expandedRoutes.push(item.path); expandedRoutes.push(item.path);
} }

View File

@ -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: '系统维护页' },
},
],
},
];

View File

@ -1,5 +1,6 @@
import { DashboardIcon } from 'tdesign-icons-vue-next';
import { shallowRef } from 'vue';
import Layout from '@/layouts/index.vue'; import Layout from '@/layouts/index.vue';
import DashboardIcon from '@/assets/assets-slide-dashboard.svg';
export default [ export default [
{ {
@ -7,19 +8,27 @@ export default [
component: Layout, component: Layout,
redirect: '/dashboard/base', redirect: '/dashboard/base',
name: 'dashboard', name: 'dashboard',
meta: { title: '仪表盘', icon: DashboardIcon }, meta: {
title: '仪表盘',
icon: shallowRef(DashboardIcon),
orderNo: 0,
},
children: [ children: [
{ {
path: 'base', path: 'base',
name: 'DashboardBase', name: 'DashboardBase',
component: () => import('@/pages/dashboard/base/index.vue'), component: () => import('@/pages/dashboard/base/index.vue'),
meta: { title: '概览仪表盘' }, meta: {
title: '概览仪表盘',
},
}, },
{ {
path: 'detail', path: 'detail',
name: 'DashboardDetail', name: 'DashboardDetail',
component: () => import('@/pages/dashboard/detail/index.vue'), component: () => import('@/pages/dashboard/detail/index.vue'),
meta: { title: '统计报表' }, meta: {
title: '统计报表',
},
}, },
], ],
}, },

View File

@ -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 文档(外链)',
},
},
],
},
];

View File

@ -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: '系统维护页' },
},
],
},
];

View File

@ -1,5 +1,6 @@
import { LogoutIcon } from 'tdesign-icons-vue-next';
import { shallowRef } from 'vue';
import Layout from '@/layouts/index.vue'; import Layout from '@/layouts/index.vue';
import LogoutIcon from '@/assets/assets-slide-logout.svg';
export default [ export default [
{ {
@ -21,7 +22,7 @@ export default [
path: '/loginRedirect', path: '/loginRedirect',
name: 'loginRedirect', name: 'loginRedirect',
redirect: '/login', redirect: '/login',
meta: { title: '登录页', icon: LogoutIcon }, meta: { title: '登录页', icon: shallowRef(LogoutIcon) },
component: () => import('@/layouts/blank.vue'), component: () => import('@/layouts/blank.vue'),
children: [ children: [
{ {

View File

@ -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<RouteRecordRaw>, roles: Array<unknown>) {
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<unknown>) {
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);
}

View File

@ -1,59 +1,41 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { RouteRecordRaw } from 'vue-router'; import { RouteRecordRaw } from 'vue-router';
import router, { asyncRouterList } from '@/router'; import router, { fixedRouterList, homepageRouterList } from '@/router';
import { store } from '@/store'; import { store } from '@/store';
import { RouteItem } from '@/api/model/permissionModel';
function filterPermissionsRouters(routes: Array<RouteRecordRaw>, roles: Array<unknown>) { import { getMenuList } from '@/api/permission';
const res = []; import { transformObjectToRoute } from '@/utils/route';
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', { export const usePermissionStore = defineStore('permission', {
state: () => ({ state: () => ({
whiteListRouters: ['/login'], whiteListRouters: ['/login'],
routers: [], routers: [],
removeRoutes: [], removeRoutes: [],
asyncRoutes: [],
}), }),
actions: { actions: {
async initRoutes(roles: Array<unknown>) { async initRoutes() {
let accessedRouters = []; const accessedRouters = this.asyncRoutes;
let removeRoutes = []; // 在菜单展示全部路由
// special token this.routers = [...homepageRouterList, ...accessedRouters, ...fixedRouterList];
if (roles.includes('all')) { // 在菜单只展示动态路由和首页
accessedRouters = asyncRouterList; // this.routers = [...homepageRouterList, ...accessedRouters];
} else { // 在菜单只展示动态路由
const res = filterPermissionsRouters(asyncRouterList, roles); // this.routers = [...accessedRouters];
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() { async buildAsyncRoutes() {
try {
// 发起菜单权限请求 获取菜单列表
const asyncRoutes: Array<RouteItem> = (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) => { this.removeRoutes.forEach((item: RouteRecordRaw) => {
router.addRoute(item); router.addRoute(item);
}); });

View File

@ -3,7 +3,7 @@ import { TOKEN_NAME } from '@/config/global';
import { store, usePermissionStore } from '@/store'; import { store, usePermissionStore } from '@/store';
const InitUserInfo = { const InitUserInfo = {
roles: [], roles: [], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用
}; };
export const useUserStore = defineStore('user', { export const useUserStore = defineStore('user', {
@ -20,7 +20,7 @@ export const useUserStore = defineStore('user', {
async login(userInfo: Record<string, unknown>) { async login(userInfo: Record<string, unknown>) {
const mockLogin = async (userInfo: Record<string, unknown>) => { const mockLogin = async (userInfo: Record<string, unknown>) => {
// 登录请求流程 // 登录请求流程
console.log(userInfo); console.log(`用户信息:`, userInfo);
// const { account, password } = userInfo; // const { account, password } = userInfo;
// if (account !== 'td') { // if (account !== 'td') {
// return { // return {
@ -57,15 +57,14 @@ export const useUserStore = defineStore('user', {
if (token === 'main_token') { if (token === 'main_token') {
return { return {
name: 'td_main', name: 'td_main',
roles: ['all'], roles: ['all'], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用
}; };
} }
return { return {
name: 'td_dev', name: 'td_dev',
roles: ['UserIndex', 'DashboardBase', 'login'], roles: ['UserIndex', 'DashboardBase', 'login'], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用
}; };
}; };
const res = await mockRemoteUserInfo(this.token); const res = await mockRemoteUserInfo(this.token);
this.userInfo = res; this.userInfo = res;
@ -80,11 +79,9 @@ export const useUserStore = defineStore('user', {
}, },
}, },
persist: { persist: {
afterRestore: (ctx) => { afterRestore: () => {
if (ctx.store.roles && ctx.store.roles.length > 0) { const permissionStore = usePermissionStore();
const permissionStore = usePermissionStore(); permissionStore.initRoutes();
permissionStore.initRoutes(ctx.store.roles);
}
}, },
}, },
}); });

View File

@ -5,6 +5,7 @@ import cloneDeep from 'lodash/cloneDeep';
import { CreateAxiosOptions } from './AxiosTransform'; import { CreateAxiosOptions } from './AxiosTransform';
import { AxiosCanceler } from './AxiosCancel'; import { AxiosCanceler } from './AxiosCancel';
import { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/axios'; import { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/axios';
import { ContentTypeEnum } from '@/constants';
// Axios模块 // Axios模块
export class VAxios { export class VAxios {
@ -64,12 +65,15 @@ export class VAxios {
// 请求配置处理 // 请求配置处理
this.instance.interceptors.request.use((config: AxiosRequestConfig) => { 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 (!ignoreRepeat) axiosCanceler.addPending(config);
if (requestInterceptors && isFunction(requestInterceptors)) { if (requestInterceptors && isFunction(requestInterceptors)) {
config = requestInterceptors(config, this.options); config = requestInterceptors(config, this.options);
} }
return config; return config;
}, undefined); }, undefined);
@ -99,7 +103,7 @@ export class VAxios {
const contentType = headers?.['Content-Type'] || headers?.['content-type']; const contentType = headers?.['Content-Type'] || headers?.['content-type'];
if ( if (
contentType !== 'application/x-www-form-urlencoded;charset=UTF-8' || contentType !== ContentTypeEnum.FormURLEncoded ||
!Reflect.has(config, 'data') || !Reflect.has(config, 'data') ||
config.method?.toUpperCase() === 'GET' config.method?.toUpperCase() === 'GET'
) { ) {

View File

@ -6,6 +6,7 @@ import { VAxios } from './Axios';
import proxy from '@/config/proxy'; import proxy from '@/config/proxy';
import { joinTimestamp, formatRequestDate, setObjToUrlParams } from './utils'; import { joinTimestamp, formatRequestDate, setObjToUrlParams } from './utils';
import { TOKEN_NAME } from '@/config/global'; import { TOKEN_NAME } from '@/config/global';
import { ContentTypeEnum } from '@/constants';
const env = import.meta.env.MODE || 'development'; const env = import.meta.env.MODE || 'development';
@ -141,7 +142,7 @@ const transform: AxiosTransform = {
resolve(config); resolve(config);
}, config.requestOptions.retry.delay || 1); }, 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)); return backoff.then((config) => request.request(config));
}, },
}; };
@ -158,7 +159,7 @@ function createAxios(opt?: Partial<CreateAxiosOptions>) {
// 携带Cookie // 携带Cookie
withCredentials: true, withCredentials: true,
// 头信息 // 头信息
headers: { 'Content-Type': 'application/json;charset=UTF-8' }, headers: { 'Content-Type': ContentTypeEnum.Json },
// 数据处理方式 // 数据处理方式
transform, transform,
// 配置项,下面的选项都可以在独立的接口请求中覆盖 // 配置项,下面的选项都可以在独立的接口请求中覆盖

View File

@ -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',
};

107
src/utils/route/index.ts Normal file
View File

@ -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<string, () => Promise<typeof import('*.vue')>>();
LayoutMap.set('LAYOUT', LAYOUT);
LayoutMap.set('BLANK', BLANK_LAYOUT);
LayoutMap.set('IFRAME', IFRAME);
let dynamicViewsModules: Record<string, () => Promise<Recordable>>;
// 动态从包内引入单个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<string, () => Promise<Recordable>>, 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<T = RouteItem>(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[];
}

View File

@ -1,6 +1,6 @@
import { ConfigEnv, UserConfig, loadEnv } from 'vite'; import { ConfigEnv, UserConfig, loadEnv } from 'vite';
import { viteMockServe } from 'vite-plugin-mock'; 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 vueJsx from '@vitejs/plugin-vue-jsx';
import svgLoader from 'vite-svg-loader'; import svgLoader from 'vite-svg-loader';
@ -32,7 +32,7 @@ export default ({ mode }: ConfigEnv): UserConfig => {
}, },
plugins: [ plugins: [
createVuePlugin(), vue(),
vueJsx(), vueJsx(),
viteMockServe({ viteMockServe({
mockPath: 'mock', mockPath: 'mock',