mirror of
https://github.com/fugary/simple-element-plus-template.git
synced 2025-12-31 03:17:49 +00:00
登录、菜单等功能,加入fastmock数据
This commit is contained in:
4
.env
4
.env
@@ -1,3 +1,7 @@
|
|||||||
# 程序名称
|
# 程序名称
|
||||||
VITE_APP_NAME=Simple Element+
|
VITE_APP_NAME=Simple Element+
|
||||||
|
# 接口地址
|
||||||
|
VITE_APP_API_BASE_URL=https://www.fastmock.site/mock/80793bea9d60828fda74202f7017e953/simple
|
||||||
|
# 超时配置
|
||||||
|
VITE_APP_API_TIMEOUT=10000
|
||||||
|
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -15,7 +15,6 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"element-plus": "^2.4.4",
|
"element-plus": "^2.4.4",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mockjs": "^1.1.0",
|
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.1",
|
"pinia-plugin-persistedstate": "^3.2.1",
|
||||||
"vue": "^3.3.13",
|
"vue": "^3.3.13",
|
||||||
@@ -893,14 +892,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
|
||||||
"version": "11.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
|
||||||
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -2564,17 +2555,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
|
||||||
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg=="
|
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg=="
|
||||||
},
|
},
|
||||||
"node_modules/mockjs": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mockjs/-/mockjs-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"commander": "*"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"random": "bin/random"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"element-plus": "^2.4.4",
|
"element-plus": "^2.4.4",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mockjs": "^1.1.0",
|
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.1",
|
"pinia-plugin-persistedstate": "^3.2.1",
|
||||||
"vue": "^3.3.13",
|
"vue": "^3.3.13",
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ html, body, #app, .index-container {
|
|||||||
background:var(--el-text-color-disabled)
|
background:var(--el-text-color-disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-form .el-card__header{
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .el-card__footer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* slide-fade动画
|
* slide-fade动画
|
||||||
*/
|
*/
|
||||||
|
|||||||
19
src/authority/index.js
Normal file
19
src/authority/index.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useLoginConfigStore } from '@/stores/LoginConfigStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查路有权限
|
||||||
|
* @param to {RouteRecordSingleViewWithChildren} 目的地路由
|
||||||
|
* @param from 出事路由
|
||||||
|
* @returns {{name: string}|boolean}
|
||||||
|
*/
|
||||||
|
export const checkRouteAuthority = (to, from) => {
|
||||||
|
const loginConfigStore = useLoginConfigStore()
|
||||||
|
if (loginConfigStore.isLoginIn()) {
|
||||||
|
// check权限
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (to.meta?.beforeLogin) { // 登录前的路由添加meta信息:beforeLogin: true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return { name: 'Login' }
|
||||||
|
}
|
||||||
@@ -76,6 +76,10 @@ defineEmits(['submitForm'])
|
|||||||
|
|
||||||
const form = ref()
|
const form = ref()
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
form
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ const menuCls = computed(() => {
|
|||||||
}
|
}
|
||||||
return menuItem.menuCls
|
return menuItem.menuCls
|
||||||
})
|
})
|
||||||
const dropdownClick = menuItem => {
|
const dropdownClick = (menuItem, $event) => {
|
||||||
if (menuItem.click) {
|
if (menuItem.click) {
|
||||||
menuItem.click()
|
menuItem.click(router, $event)
|
||||||
} else {
|
} else {
|
||||||
const route = menuItem.route || menuItem.index
|
const route = menuItem.route || menuItem.index
|
||||||
if (route) {
|
if (route) {
|
||||||
@@ -98,7 +98,7 @@ const dropdownClick = menuItem => {
|
|||||||
v-else-if="isDropdown"
|
v-else-if="isDropdown"
|
||||||
:key="menuItem.index||index"
|
:key="menuItem.index||index"
|
||||||
:class="menuCls"
|
:class="menuCls"
|
||||||
@click="menuItem.click&&menuItem.click()"
|
@click="menuItem.click&&menuItem.click(router, $event)"
|
||||||
>
|
>
|
||||||
<el-dropdown class="common-dropdown">
|
<el-dropdown class="common-dropdown">
|
||||||
<span class="el-dropdown-link">
|
<span class="el-dropdown-link">
|
||||||
@@ -114,7 +114,7 @@ const dropdownClick = menuItem => {
|
|||||||
<el-dropdown-item
|
<el-dropdown-item
|
||||||
v-for="(childMenu, childIdx) in menuItem.children"
|
v-for="(childMenu, childIdx) in menuItem.children"
|
||||||
:key="childMenu.index||childIdx"
|
:key="childMenu.index||childIdx"
|
||||||
@click="dropdownClick(childMenu)"
|
@click="dropdownClick(childMenu, $event)"
|
||||||
>
|
>
|
||||||
<common-icon
|
<common-icon
|
||||||
:size="childMenu.iconSize"
|
:size="childMenu.iconSize"
|
||||||
@@ -133,7 +133,7 @@ const dropdownClick = menuItem => {
|
|||||||
:route="menuItem.route"
|
:route="menuItem.route"
|
||||||
v-bind="menuItem.attrs"
|
v-bind="menuItem.attrs"
|
||||||
:index="menuItem.index"
|
:index="menuItem.index"
|
||||||
@click="menuItem.click&&menuItem.click()"
|
@click="menuItem.click&&menuItem.click(router, $event)"
|
||||||
>
|
>
|
||||||
<common-icon
|
<common-icon
|
||||||
:size="menuItem.iconSize"
|
:size="menuItem.iconSize"
|
||||||
|
|||||||
15
src/config.js
Normal file
15
src/config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 默认单页数量
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
export const PAGE_SIZE = 10
|
||||||
|
/**
|
||||||
|
* 分页数量选项
|
||||||
|
* @type {number[]}
|
||||||
|
*/
|
||||||
|
export const PAGE_SIZE_LIST = [10, 20, 50]
|
||||||
|
/**
|
||||||
|
* 默认分页跳转
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
export const PAGE_LAYOUT = 'total, prev, pager, next'
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
||||||
import { useMenuStore } from '@/stores/MenuStore'
|
import { useMenuConfigStore } from '@/stores/MenuConfigStore'
|
||||||
|
import { computed } from 'vue'
|
||||||
const globalConfigStore = useGlobalConfigStore()
|
const globalConfigStore = useGlobalConfigStore()
|
||||||
const menuStore = useMenuStore()
|
const menuConfigStore = useMenuConfigStore()
|
||||||
|
const businessMenus = computed(() => menuConfigStore.calcBusinessMenus())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -10,7 +12,7 @@ const menuStore = useMenuStore()
|
|||||||
<common-menu
|
<common-menu
|
||||||
class="el-menu-left"
|
class="el-menu-left"
|
||||||
:collapse="globalConfigStore.isCollapseLeft"
|
:collapse="globalConfigStore.isCollapseLeft"
|
||||||
:menus="menuStore.businessMenus"
|
:menus="businessMenus"
|
||||||
:default-openeds="['1']"
|
:default-openeds="['1']"
|
||||||
/>
|
/>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
||||||
import { useMenuStore } from '@/stores/MenuStore'
|
import { useMenuConfigStore } from '@/stores/MenuConfigStore'
|
||||||
import { GlobalLayoutMode } from '@/consts/GlobalConstants'
|
import { GlobalLayoutMode } from '@/consts/GlobalConstants'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
const globalConfigStore = useGlobalConfigStore()
|
const globalConfigStore = useGlobalConfigStore()
|
||||||
const menuStore = useMenuStore()
|
const menuConfigStore = useMenuConfigStore()
|
||||||
|
|
||||||
const allMenus = computed(() => {
|
const allMenus = computed(() => {
|
||||||
const topMenus = menuStore.baseTopMenus
|
const topMenus = menuConfigStore.baseTopMenus
|
||||||
const businessMenus = menuStore.businessMenus
|
const businessMenus = menuConfigStore.calcBusinessMenus()
|
||||||
if (globalConfigStore.layoutMode === GlobalLayoutMode.TOP) {
|
if (globalConfigStore.layoutMode === GlobalLayoutMode.TOP) {
|
||||||
return [...businessMenus, ...topMenus.slice(1)]
|
return [...businessMenus, ...topMenus.slice(1)]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,14 @@ common.label.cachedTabMode = '缓存标签页'
|
|||||||
common.label.showTabIcon = '标签图标'
|
common.label.showTabIcon = '标签图标'
|
||||||
common.label.keywords = '关键字'
|
common.label.keywords = '关键字'
|
||||||
common.label.breadcrumb = '面包屑导航'
|
common.label.breadcrumb = '面包屑导航'
|
||||||
|
common.label.username = '用户名'
|
||||||
|
common.label.password = '密码'
|
||||||
|
|
||||||
//* =======================msg=====================//
|
//* =======================msg=====================//
|
||||||
common.msg.nonNull = '{0}不能为空'
|
common.msg.nonNull = '{0}不能为空'
|
||||||
common.msg.patternInvalid = '{0}格式校验不通过'
|
common.msg.patternInvalid = '{0}格式校验不通过'
|
||||||
common.msg.pleaseSelectIcon = '请选择图标'
|
common.msg.pleaseSelectIcon = '请选择图标'
|
||||||
common.msg.inputKeywords = '输入关键字搜索'
|
common.msg.inputKeywords = '输入关键字搜索'
|
||||||
|
common.msg.networkError = '网络异常,请稍后再试.'
|
||||||
|
common.msg.networkTimeout = '系统处理超时,请稍后再试.'
|
||||||
|
common.msg.loginTitle = '用户登录'
|
||||||
|
|||||||
@@ -40,9 +40,14 @@ common.label.cachedTabMode = 'Cache Tabs'
|
|||||||
common.label.showTabIcon = 'Tab Icon'
|
common.label.showTabIcon = 'Tab Icon'
|
||||||
common.label.keywords = 'Keywords'
|
common.label.keywords = 'Keywords'
|
||||||
common.label.breadcrumb = 'Breadcrumb'
|
common.label.breadcrumb = 'Breadcrumb'
|
||||||
|
common.label.username = 'User Name'
|
||||||
|
common.label.password = 'Password'
|
||||||
|
|
||||||
//* =======================msg=====================//
|
//* =======================msg=====================//
|
||||||
common.msg.nonNull = '{0} is required.'
|
common.msg.nonNull = '{0} is required.'
|
||||||
common.msg.patternInvalid = '{0} pattern check failed.'
|
common.msg.patternInvalid = '{0} pattern check failed.'
|
||||||
common.msg.pleaseSelectIcon = 'Please select icon'
|
common.msg.pleaseSelectIcon = 'Please select icon'
|
||||||
common.msg.inputKeywords = 'Input keywords to search'
|
common.msg.inputKeywords = 'Input keywords to search'
|
||||||
|
common.msg.networkError = 'Network error, please try later.'
|
||||||
|
common.msg.networkTimeout = 'System process timeout, please try later.'
|
||||||
|
common.msg.loginTitle = 'User Login'
|
||||||
|
|||||||
@@ -41,9 +41,8 @@ export const $changeLocale = locale => {
|
|||||||
* @param {boolean} replaceEmpty 为空是否用不为空的数据代替
|
* @param {boolean} replaceEmpty 为空是否用不为空的数据代替
|
||||||
* @returns {*}
|
* @returns {*}
|
||||||
*/
|
*/
|
||||||
export const $i18nMsg = (cn, en, replaceEmpty) => {
|
export const $i18nMsg = (cn, en, replaceEmpty = true) => {
|
||||||
const { currentLocale } = useGlobalConfigStore()
|
const { currentLocale } = useGlobalConfigStore()
|
||||||
console.log(currentLocale)
|
|
||||||
if (currentLocale === GlobalLocales.CN) {
|
if (currentLocale === GlobalLocales.CN) {
|
||||||
return replaceEmpty ? (cn || en) : cn
|
return replaceEmpty ? (cn || en) : cn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import HomeView from '@/views/HomeView.vue'
|
import Login from '@/views/Login.vue'
|
||||||
import AdminRoutes from '@/route/AdminRoutes'
|
import AdminRoutes from '@/route/AdminRoutes'
|
||||||
import ToolsRoutes from '@/route/ToolsRoutes'
|
import ToolsRoutes from '@/route/ToolsRoutes'
|
||||||
|
import { checkRouteAuthority } from '@/authority'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
@@ -9,7 +10,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
component: HomeView,
|
component: () => import('@/views/HomeView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
icon: 'HomeFilled',
|
icon: 'HomeFilled',
|
||||||
labelKey: 'common.label.index'
|
labelKey: 'common.label.index'
|
||||||
@@ -43,8 +44,17 @@ const router = createRouter({
|
|||||||
...AdminRoutes,
|
...AdminRoutes,
|
||||||
...ToolsRoutes
|
...ToolsRoutes
|
||||||
]
|
]
|
||||||
|
}, {
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login,
|
||||||
|
meta: {
|
||||||
|
beforeLogin: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach(checkRouteAuthority)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { GlobalLocales } from '@/consts/GlobalConstants'
|
|
||||||
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
|
||||||
import { loadBusinessMenus } from '@/services/mock/MockGlobalService'
|
|
||||||
|
|
||||||
export const useBaseTopMenus = () => {
|
|
||||||
const globalConfigStore = useGlobalConfigStore()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
iconIf: () => globalConfigStore.isCollapseLeft ? 'expand' : 'fold',
|
|
||||||
click: globalConfigStore.collapseLeft
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isSplit: true,
|
|
||||||
menuCls: 'flex-grow'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'LanguageFilled',
|
|
||||||
isDropdown: true,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
iconIf: () => GlobalLocales.CN === globalConfigStore.currentLocale ? 'check' : '',
|
|
||||||
labelKey: 'common.label.langCn',
|
|
||||||
click: () => globalConfigStore.changeLocale(GlobalLocales.CN)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
iconIf: () => GlobalLocales.EN === globalConfigStore.currentLocale ? 'check' : '',
|
|
||||||
labelKey: 'common.label.langEn',
|
|
||||||
click: () => globalConfigStore.changeLocale(GlobalLocales.EN)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isDropdown: true,
|
|
||||||
iconIf: () => !globalConfigStore.isDarkTheme ? 'moon' : 'sunny',
|
|
||||||
click: () => globalConfigStore.changeTheme(!globalConfigStore.isDarkTheme)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isDropdown: true,
|
|
||||||
icon: 'Setting',
|
|
||||||
click: () => globalConfigStore.changeShowSettings(true)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'user',
|
|
||||||
isDropdown: true,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
labelKey: 'common.label.personalInfo',
|
|
||||||
index: '/personal'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
labelKey: 'common.label.about',
|
|
||||||
index: '/about'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
labelKey: 'common.label.logout',
|
|
||||||
index: '/logout'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBusinessMenus = () => {
|
|
||||||
return loadBusinessMenus()
|
|
||||||
}
|
|
||||||
13
src/services/login/LoginService.js
Normal file
13
src/services/login/LoginService.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {Object} LoginVo
|
||||||
|
* @property {string} userName
|
||||||
|
* @property {string} userPassword
|
||||||
|
*/
|
||||||
|
import { $httpPost } from '@/vendors/axios'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param loginVo {LoginVo} 登录账号
|
||||||
|
*/
|
||||||
|
export const login = loginVo => {
|
||||||
|
return $httpPost('/login', loginVo, { addToken: false, isLogin: true })
|
||||||
|
}
|
||||||
136
src/services/menu/MenuService.js
Normal file
136
src/services/menu/MenuService.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {Object} MenuDto
|
||||||
|
* @property {number} id 主键
|
||||||
|
* @property {number} parentId 上级id
|
||||||
|
* @property {string} iconCls 图标
|
||||||
|
* @property {string} nameCn 中文名
|
||||||
|
* @property {string} nameEn 英文名
|
||||||
|
* @property {string} menuUrl 链接地址
|
||||||
|
* @property {[MenuDto]} children 子菜单
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $httpPost } from '@/vendors/axios'
|
||||||
|
import { $i18nMsg } from '@/messages'
|
||||||
|
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
||||||
|
import { GlobalLocales } from '@/consts/GlobalConstants'
|
||||||
|
import { useLoginConfigStore } from '@/stores/LoginConfigStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接口菜单格式转换成页面展示格式
|
||||||
|
* @param menu {MenuDto}
|
||||||
|
* @return {CommonMenuItem}
|
||||||
|
*/
|
||||||
|
export const menu2CommonMenu = (menu) => {
|
||||||
|
/**
|
||||||
|
* @type {CommonMenuItem}
|
||||||
|
*/
|
||||||
|
const menuItem = {
|
||||||
|
icon: menu.iconCls,
|
||||||
|
label: $i18nMsg(menu.nameCn, menu.nameEn),
|
||||||
|
index: menu.menuUrl
|
||||||
|
}
|
||||||
|
if (menu.children) {
|
||||||
|
menuItem.children = menu.children.map(menu2CommonMenu)
|
||||||
|
}
|
||||||
|
return menuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadAndParseMenus = async config => {
|
||||||
|
/**
|
||||||
|
* @type {[MenuDto]}
|
||||||
|
*/
|
||||||
|
const menus = await $httpPost('/api/menus', config).then(data => data.resultData?.menuList || [])
|
||||||
|
return processMenus(menus)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 解析菜单信息
|
||||||
|
* @param {[MenuDto]} menus
|
||||||
|
* @param {MenuDto} parent
|
||||||
|
* @returns {*[]}
|
||||||
|
*/
|
||||||
|
const processMenus = (menus, parent = undefined) => {
|
||||||
|
const results = []
|
||||||
|
menus.forEach(currentMenu => {
|
||||||
|
if (!parent) {
|
||||||
|
if (!currentMenu.parentId) { // 根节点
|
||||||
|
results.push(currentMenu)
|
||||||
|
processMenus(menus, currentMenu)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentMenu.parentId === parent.id) {
|
||||||
|
parent.children = parent.children || []
|
||||||
|
parent.children.push(currentMenu)
|
||||||
|
processMenus(menus, currentMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parent
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeAndLocaleMenus = () => {
|
||||||
|
const globalConfigStore = useGlobalConfigStore()
|
||||||
|
return [{
|
||||||
|
icon: 'LanguageFilled',
|
||||||
|
isDropdown: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
iconIf: () => GlobalLocales.CN === globalConfigStore.currentLocale ? 'check' : '',
|
||||||
|
labelKey: 'common.label.langCn',
|
||||||
|
click: () => globalConfigStore.changeLocale(GlobalLocales.CN)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconIf: () => GlobalLocales.EN === globalConfigStore.currentLocale ? 'check' : '',
|
||||||
|
labelKey: 'common.label.langEn',
|
||||||
|
click: () => globalConfigStore.changeLocale(GlobalLocales.EN)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isDropdown: true,
|
||||||
|
iconIf: () => !globalConfigStore.isDarkTheme ? 'moon' : 'sunny',
|
||||||
|
click: () => globalConfigStore.changeTheme(!globalConfigStore.isDarkTheme)
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBaseTopMenus = () => {
|
||||||
|
const globalConfigStore = useGlobalConfigStore()
|
||||||
|
const loginConfigStore = useLoginConfigStore()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
iconIf: () => globalConfigStore.isCollapseLeft ? 'expand' : 'fold',
|
||||||
|
click: globalConfigStore.collapseLeft
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isSplit: true,
|
||||||
|
menuCls: 'flex-grow'
|
||||||
|
},
|
||||||
|
...useThemeAndLocaleMenus(),
|
||||||
|
{
|
||||||
|
isDropdown: true,
|
||||||
|
icon: 'Setting',
|
||||||
|
click: () => globalConfigStore.changeShowSettings(true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'user',
|
||||||
|
isDropdown: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
labelKey: 'common.label.personalInfo',
|
||||||
|
index: '/personal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: 'common.label.about',
|
||||||
|
index: '/about'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: 'common.label.logout',
|
||||||
|
click (router) {
|
||||||
|
loginConfigStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { $i18nBundle } from '@/messages'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 测试假数据
|
|
||||||
* @returns {Promise<unknown>}
|
|
||||||
*/
|
|
||||||
export const loadBusinessMenus = () => {
|
|
||||||
// 模拟加载业务数据
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve([
|
|
||||||
{
|
|
||||||
icon: 'HomeFilled',
|
|
||||||
index: '/',
|
|
||||||
labelIf: () => $i18nBundle('common.label.title')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'setting',
|
|
||||||
labelKey: 'menu.label.systemManagement',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
index: '/admin/users',
|
|
||||||
icon: 'user',
|
|
||||||
labelKey: 'menu.label.userManagement'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: '/admin/roles',
|
|
||||||
icon: 'GroupFilled',
|
|
||||||
labelKey: 'menu.label.roleManagement'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: '/admin/authority',
|
|
||||||
icon: 'lock',
|
|
||||||
labelKey: 'menu.label.authorityManagement'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: '/admin/menus',
|
|
||||||
icon: 'menu',
|
|
||||||
labelKey: 'menu.label.menuManagement'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'BuildFilled',
|
|
||||||
labelKey: 'menu.label.toolsManagement',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
index: '/not-found',
|
|
||||||
icon: 'WarningFilled',
|
|
||||||
labelKey: 'menu.label.errorPage404'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: '/icons',
|
|
||||||
icon: 'InsertEmoticonOutlined',
|
|
||||||
labelKey: 'menu.label.toolsIcons'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: '/forms',
|
|
||||||
icon: 'TableRowsFilled',
|
|
||||||
labelKey: 'menu.label.toolsForms'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: '/tables',
|
|
||||||
icon: 'Grid',
|
|
||||||
labelKey: 'menu.label.toolsTables'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: '/tests',
|
|
||||||
icon: 'TipsAndUpdatesOutlined',
|
|
||||||
labelKey: 'menu.label.toolsTests'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
51
src/stores/LoginConfigStore.js
Normal file
51
src/stores/LoginConfigStore.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { login } from '@/services/login/LoginService'
|
||||||
|
import { useTabsViewStore } from '@/stores/TabsViewStore'
|
||||||
|
import { useMenuConfigStore } from '@/stores/MenuConfigStore'
|
||||||
|
|
||||||
|
export const useLoginConfigStore = defineStore('loginConfig', () => {
|
||||||
|
/**
|
||||||
|
* 登录成功后保存accessToken
|
||||||
|
* @type {Ref<string>}
|
||||||
|
*/
|
||||||
|
const accessToken = ref('')
|
||||||
|
/**
|
||||||
|
* 保存登录用户信息
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
const accountInfo = ref()
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
accountInfo,
|
||||||
|
/**
|
||||||
|
* @param {{account: Object, accessToken:string}} loginResult
|
||||||
|
*/
|
||||||
|
setLoginAccountInfo (loginResult) {
|
||||||
|
accountInfo.value = loginResult.account
|
||||||
|
accessToken.value = loginResult.accessToken
|
||||||
|
},
|
||||||
|
clearLoginInfo () {
|
||||||
|
accessToken.value = ''
|
||||||
|
accountInfo.value = null
|
||||||
|
},
|
||||||
|
isLoginIn () {
|
||||||
|
return !!accessToken.value
|
||||||
|
},
|
||||||
|
logout () {
|
||||||
|
// 清理登录数据
|
||||||
|
this.clearLoginInfo()
|
||||||
|
// 清理TAB数据, $reset似乎不能用
|
||||||
|
useTabsViewStore().clearAllTabs()
|
||||||
|
useMenuConfigStore().clearBusinessMenus()
|
||||||
|
},
|
||||||
|
async login (loginVo) {
|
||||||
|
const loginResult = await login(loginVo)
|
||||||
|
if (loginResult.success) {
|
||||||
|
this.setLoginAccountInfo(loginResult.resultData)
|
||||||
|
}
|
||||||
|
return loginResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
37
src/stores/MenuConfigStore.js
Normal file
37
src/stores/MenuConfigStore.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { loadAndParseMenus, menu2CommonMenu, useBaseTopMenus } from '@/services/menu/MenuService'
|
||||||
|
|
||||||
|
export const useMenuConfigStore = defineStore('menuConfig', () => {
|
||||||
|
/**
|
||||||
|
* @type {[CommonMenuItem]}
|
||||||
|
*/
|
||||||
|
const baseTopMenus = ref([])
|
||||||
|
/**
|
||||||
|
* @type {[MenuDto]}
|
||||||
|
*/
|
||||||
|
const businessMenus = ref([])
|
||||||
|
return {
|
||||||
|
baseTopMenus,
|
||||||
|
businessMenus,
|
||||||
|
loadBaseTopMenus () {
|
||||||
|
baseTopMenus.value = useBaseTopMenus()
|
||||||
|
},
|
||||||
|
async loadBusinessMenus () {
|
||||||
|
businessMenus.value = await loadAndParseMenus()
|
||||||
|
return this.calcBusinessMenus()
|
||||||
|
},
|
||||||
|
calcBusinessMenus () {
|
||||||
|
return [{
|
||||||
|
icon: 'HomeFilled',
|
||||||
|
index: '/',
|
||||||
|
labelKey: 'common.label.title'
|
||||||
|
}, ...businessMenus.value.map(menu2CommonMenu)]
|
||||||
|
},
|
||||||
|
clearBusinessMenus () {
|
||||||
|
businessMenus.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
persist: true
|
||||||
|
})
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useBaseTopMenus, useBusinessMenus } from '@/services/global/GlobalService'
|
|
||||||
|
|
||||||
export const useMenuStore = defineStore('menu', () => {
|
|
||||||
/**
|
|
||||||
* @type {[CommonMenuItem]}
|
|
||||||
*/
|
|
||||||
const baseTopMenus = ref([])
|
|
||||||
/**
|
|
||||||
* @type {[CommonMenuItem]}
|
|
||||||
*/
|
|
||||||
const businessMenus = ref([])
|
|
||||||
return {
|
|
||||||
baseTopMenus,
|
|
||||||
businessMenus,
|
|
||||||
loadBaseTopMenus () {
|
|
||||||
baseTopMenus.value = useBaseTopMenus()
|
|
||||||
console.info('顶部数据', businessMenus.value)
|
|
||||||
},
|
|
||||||
async loadBusinessMenus () {
|
|
||||||
businessMenus.value = await useBusinessMenus()
|
|
||||||
console.info('菜单数据', businessMenus.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
persist: true
|
|
||||||
})
|
|
||||||
@@ -27,6 +27,11 @@ export const useTabsViewStore = defineStore('tabsView', () => {
|
|||||||
*/
|
*/
|
||||||
const cachedTabs = ref([])
|
const cachedTabs = ref([])
|
||||||
|
|
||||||
|
const clearAllTabs = () => {
|
||||||
|
historyTabs.value = []
|
||||||
|
cachedTabs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
const clearHistoryTabs = () => {
|
const clearHistoryTabs = () => {
|
||||||
if (historyTabs.value.length) {
|
if (historyTabs.value.length) {
|
||||||
let idx = historyTabs.value.findIndex(v => currentTab.value && v.path === currentTab.value)
|
let idx = historyTabs.value.findIndex(v => currentTab.value && v.path === currentTab.value)
|
||||||
@@ -163,6 +168,7 @@ export const useTabsViewStore = defineStore('tabsView', () => {
|
|||||||
removeHistoryTab,
|
removeHistoryTab,
|
||||||
removeOtherHistoryTabs,
|
removeOtherHistoryTabs,
|
||||||
removeHistoryTabs,
|
removeHistoryTabs,
|
||||||
|
clearAllTabs,
|
||||||
clearHistoryTabs,
|
clearHistoryTabs,
|
||||||
findHistoryTab,
|
findHistoryTab,
|
||||||
addHistoryTab,
|
addHistoryTab,
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { defineStore, createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
|
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
|
||||||
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
|
||||||
import { useTabsViewStore } from '@/stores/TabsViewStore'
|
|
||||||
|
|
||||||
export const useStore = defineStore('store', () => {
|
|
||||||
return {
|
|
||||||
globalConfig: useGlobalConfigStore(),
|
|
||||||
tabsView: useTabsViewStore()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
install (app) {
|
install (app) {
|
||||||
|
|||||||
53
src/vendors/axios.js
vendored
Normal file
53
src/vendors/axios.js
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
||||||
|
import { useLoginConfigStore } from '@/stores/LoginConfigStore'
|
||||||
|
import { $i18nBundle } from '@/messages'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { debounce } from 'lodash'
|
||||||
|
|
||||||
|
export const $http = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
|
||||||
|
timeout: import.meta.env.VITE_APP_API_TIMEOUT
|
||||||
|
})
|
||||||
|
|
||||||
|
$http.interceptors.request.use(config => {
|
||||||
|
const globalConfigStore = useGlobalConfigStore()
|
||||||
|
const loginConfigStore = useLoginConfigStore()
|
||||||
|
config.headers.locale = globalConfigStore.currentLocale
|
||||||
|
if (config.addToken !== false && loginConfigStore.accessToken) { // 添加token
|
||||||
|
config.headers.Authorization = `Bearer ${loginConfigStore.accessToken}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
const networkErrorFun = debounce(() => ElMessage.error($i18nBundle('common.msg.networkError')), 300)
|
||||||
|
const networkTimeoutFun = debounce(() => ElMessage.error($i18nBundle('common.msg.networkTimeout')), 300)
|
||||||
|
|
||||||
|
$http.interceptors.response.use(data => {
|
||||||
|
// todo 其他处理
|
||||||
|
return data
|
||||||
|
}, error => {
|
||||||
|
console.info(error.code, error.message)
|
||||||
|
if (error.message === 'Network Error') {
|
||||||
|
networkErrorFun()
|
||||||
|
} else if (error.code === 'ECONNABORTED' && error.message.indexOf('timeout') > -1) {
|
||||||
|
networkTimeoutFun()
|
||||||
|
}
|
||||||
|
if (error.response.status === 401 && !error.response.config.isLogin) {
|
||||||
|
// 跳转登录页面
|
||||||
|
}
|
||||||
|
return error.response
|
||||||
|
})
|
||||||
|
|
||||||
|
export const $httpPost = (url, data, config) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
$http.post(url, data, config).then(response => {
|
||||||
|
if (response.data) {
|
||||||
|
resolve(response.data) // 只要有数据就认为成功,内容再解析
|
||||||
|
} else {
|
||||||
|
reject(new Error('No response data'))
|
||||||
|
}
|
||||||
|
}, reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,18 +3,18 @@ import LeftMenu from '@/layout/LeftMenu.vue'
|
|||||||
import TopNav from '@/layout/TopNav.vue'
|
import TopNav from '@/layout/TopNav.vue'
|
||||||
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
|
||||||
import { useTabsViewStore } from '@/stores/TabsViewStore'
|
import { useTabsViewStore } from '@/stores/TabsViewStore'
|
||||||
import { useMenuStore } from '@/stores/MenuStore'
|
import { useMenuConfigStore } from '@/stores/MenuConfigStore'
|
||||||
import { GlobalLayoutMode } from '@/consts/GlobalConstants'
|
import { GlobalLayoutMode } from '@/consts/GlobalConstants'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import GlobalSettings from '@/views/components/global/GlobalSettings.vue'
|
import GlobalSettings from '@/views/components/global/GlobalSettings.vue'
|
||||||
const globalConfigStore = useGlobalConfigStore()
|
const globalConfigStore = useGlobalConfigStore()
|
||||||
const menuStore = useMenuStore()
|
const menuConfigStore = useMenuConfigStore()
|
||||||
const tabsViewStore = useTabsViewStore()
|
const tabsViewStore = useTabsViewStore()
|
||||||
const showLeftMenu = computed(() => {
|
const showLeftMenu = computed(() => {
|
||||||
return globalConfigStore.layoutMode === GlobalLayoutMode.LEFT
|
return globalConfigStore.layoutMode === GlobalLayoutMode.LEFT
|
||||||
})
|
})
|
||||||
menuStore.loadBaseTopMenus()
|
menuConfigStore.loadBaseTopMenus()
|
||||||
menuStore.loadBusinessMenus()
|
menuConfigStore.loadBusinessMenus()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
105
src/views/Login.vue
Normal file
105
src/views/Login.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useThemeAndLocaleMenus } from '@/services/menu/MenuService'
|
||||||
|
import { useLoginConfigStore } from '@/stores/LoginConfigStore'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loginConfigStore = useLoginConfigStore()
|
||||||
|
|
||||||
|
const themeAndLocaleMenus = ref(useThemeAndLocaleMenus())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {[CommonFormOption]}
|
||||||
|
*/
|
||||||
|
const loginFormOptions = [{
|
||||||
|
labelKey: 'common.label.username',
|
||||||
|
required: true,
|
||||||
|
prop: 'userName'
|
||||||
|
}, {
|
||||||
|
labelKey: 'common.label.password',
|
||||||
|
required: true,
|
||||||
|
prop: 'userPassword',
|
||||||
|
attrs: {
|
||||||
|
showPassword: true
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {LoginVo}
|
||||||
|
*/
|
||||||
|
const loginVo = ref({
|
||||||
|
userName: 'admin',
|
||||||
|
userPassword: '123456'
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitForm = form => {
|
||||||
|
form.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
const loginResult = await loginConfigStore.login(loginVo.value)
|
||||||
|
if (loginResult.success) {
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(loginResult.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const formRef = ref()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-container>
|
||||||
|
<el-card class="login-form">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>{{ $t('common.msg.loginTitle') }}</span>
|
||||||
|
<common-menu
|
||||||
|
:menus="themeAndLocaleMenus"
|
||||||
|
mode="horizontal"
|
||||||
|
:ellipsis="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<common-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="loginVo"
|
||||||
|
:options="loginFormOptions"
|
||||||
|
label-width="100px"
|
||||||
|
:show-buttons="false"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="submitForm(formRef.form)"
|
||||||
|
>
|
||||||
|
{{ $t('common.label.submit') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
@click="formRef.form.resetFields()"
|
||||||
|
>
|
||||||
|
{{ $t('common.label.reset') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-form {
|
||||||
|
width: 500px;
|
||||||
|
margin: 5% auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu--horizontal.el-menu {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user