菜单权限

master
lion 6 months ago
parent 70829320cc
commit 3de809b187

7
package-lock.json generated

@ -14,6 +14,7 @@
"element-plus": "^2.6.3",
"js-cookie": "^3.0.5",
"mermaid": "^11.12.2",
"path-browserify": "^1.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
@ -3390,6 +3391,12 @@
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"license": "MIT"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/path-data-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",

@ -17,6 +17,7 @@
"element-plus": "^2.6.3",
"js-cookie": "^3.0.5",
"mermaid": "^11.12.2",
"path-browserify": "^1.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@ -24,121 +24,7 @@
<!-- 主容器 -->
<div class="main-container">
<!-- 侧边导航 -->
<aside class="sidebar" :class="{ collapse: isCollapse }">
<el-menu
:default-active="activeMenu"
class="nav-menu"
router
:collapse="isCollapse"
:collapse-transition="false"
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-sub-menu index="pre-approval">
<template #title>
<el-icon><DocumentChecked /></el-icon>
<span>事前流程</span>
</template>
<el-menu-item index="/pre-approval/start-process">
<el-icon><Plus /></el-icon>
<template #title>发起流程</template>
</el-menu-item>
<el-menu-item index="/pre-approval/process-query">
<el-icon><Search /></el-icon>
<template #title>流程查询</template>
</el-menu-item>
<el-menu-item index="/settings/pre-approval-process-config">
<el-icon><Setting /></el-icon>
<template #title>事前流程设置</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="payment">
<template #title>
<el-icon><Money /></el-icon>
<span>支付流程</span>
</template>
<el-menu-item index="/payment/direct-payment">
<el-icon><Money /></el-icon>
<template #title>直接支付</template>
</el-menu-item>
<el-menu-item index="/payment/indirect-payment">
<el-icon><OfficeBuilding /></el-icon>
<template #title>非直接支付</template>
</el-menu-item>
<!-- 暂时隐藏流程查询菜单项 -->
<!-- <el-menu-item index="/payment/process-query">
<el-icon><Search /></el-icon>
<template #title>流程查询</template>
</el-menu-item> -->
<el-menu-item index="/payment/payment-query">
<el-icon><List /></el-icon>
<template #title>付款查询</template>
</el-menu-item>
<el-menu-item index="/payment/draft-query">
<el-icon><Document /></el-icon>
<template #title>暂存流程</template>
</el-menu-item>
<el-menu-item index="/payment/contract-management">
<el-icon><Document /></el-icon>
<template #title>合同管理</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="funds">
<template #title>
<el-icon><Wallet /></el-icon>
<span>资金管理</span>
</template>
<el-menu-item index="/funds/budget-management">
<el-icon><FolderOpened /></el-icon>
<template #title>预算录入</template>
</el-menu-item>
<el-menu-item index="/funds/budget">
<el-icon><DataAnalysis /></el-icon>
<template #title>预算查询</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="settings">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="/settings/planned-expenditure-category">
<el-icon><FolderOpened /></el-icon>
<template #title>非直接支出分类</template>
</el-menu-item>
<el-menu-item index="/settings/payment-category">
<el-icon><FolderOpened /></el-icon>
<template #title>付款分类</template>
</el-menu-item>
<el-menu-item index="/settings/template-element-settings">
<el-icon><Grid /></el-icon>
<template #title>非直接支付模版元素</template>
</el-menu-item>
<el-menu-item index="/settings/payment-template-element-settings">
<el-icon><Grid /></el-icon>
<template #title>付款模版元素</template>
</el-menu-item>
<el-menu-item index="/settings/pre-approval-template-settings">
<el-icon><List /></el-icon>
<template #title>事前流程模版设置</template>
</el-menu-item>
<el-menu-item index="/settings/contract-settings">
<el-icon><Setting /></el-icon>
<template #title>合同管理设置</template>
</el-menu-item>
</el-sub-menu>
</el-menu>
<div class="sidebar-toggle" @click="toggleCollapse">
<el-icon><Fold v-if="!isCollapse" /><Expand v-else /></el-icon>
</div>
</aside>
<Sidebar />
<!-- 主内容区域 -->
<main class="main-content">
@ -151,35 +37,20 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/store/user'
import {
Wallet,
ArrowDown,
Right,
Odometer,
Plus,
Search,
Money,
OfficeBuilding,
Document,
DataAnalysis,
Fold,
Expand,
Setting,
FolderOpened,
Grid,
List
Right
} from '@element-plus/icons-vue'
import Sidebar from './components/Sidebar/index.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const isCollapse = ref(false)
// iframe false onMounted
const isInIframe = ref(false)
@ -188,34 +59,6 @@ const username = computed(() => {
return userStore.username || userStore.name || localStorage.getItem('username') || '管理员'
})
//
const activeMenu = computed(() => {
return route.path
})
//
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
}
//
const collapseSidebar = () => {
isCollapse.value = true
}
//
const expandSidebar = () => {
isCollapse.value = false
}
// 访 planned-expenditure-template-settings
//
// watch(() => route.path, (newPath) => {
// if (newPath === '/settings/planned-expenditure-template-settings') {
// collapseSidebar()
// }
// }, { immediate: true })
//
const showUserMenu = () => {
ElMessage.info('用户菜单功能待开发')
@ -272,11 +115,6 @@ onMounted(async () => {
// 访 window.top iframe
isInIframe.value = false
}
//
// if (route.path === '/settings/planned-expenditure-template-settings') {
// collapseSidebar()
// }
})
</script>
@ -380,81 +218,10 @@ onMounted(async () => {
overflow: hidden;
}
/* 侧边栏 */
.sidebar {
width: 260px;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05);
border-right: 1px solid #f5f7fa;
display: flex;
flex-direction: column;
transition: width 0.3s;
height: 100%;
overflow: hidden;
}
.sidebar.collapse {
width: 64px;
}
.nav-menu {
flex: 1;
border-right: none;
overflow-y: auto;
overflow-x: hidden;
}
/* 自定义滚动条样式 */
.nav-menu::-webkit-scrollbar {
width: 6px;
}
.nav-menu::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
.nav-menu::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 3px;
}
.nav-menu::-webkit-scrollbar-thumb:hover {
background: #a0a4ac;
}
.sidebar-toggle {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-top: 1px solid #e4e7ed;
transition: background-color 0.3s;
}
.sidebar-toggle:hover {
background-color: #f5f7fa;
}
/* 主内容 */
.main-content {
flex: 1;
overflow: auto;
background: #f5f7fa;
}
/* 响应式 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -260px;
z-index: 1000;
height: calc(100vh - 60px);
}
.sidebar:not(.collapse) {
left: 0;
}
}
</style>

@ -0,0 +1,68 @@
<template>
<span>
<el-icon v-if="iconComponent">
<component :is="iconComponent" />
</el-icon>
<el-icon v-else>
<Menu />
</el-icon>
<span v-if="title && !isCollapse" class="menu-title">{{ title }}</span>
</span>
</template>
<script setup>
import { computed } from 'vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { Menu } from '@element-plus/icons-vue'
const props = defineProps({
icon: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
isCollapse: {
type: Boolean,
default: false
}
})
//
const iconComponent = computed(() => {
if (!props.icon) {
return null
}
// 使PascalCase ListMenuDocument
if (ElementPlusIconsVue[props.icon]) {
return ElementPlusIconsVue[props.icon]
}
// el-icon-xxx kebab-case
if (props.icon.includes('el-icon')) {
if (props.icon.startsWith('el-icon-')) {
// el-icon-
const iconName = props.icon.replace('el-icon-', '')
// kebab-case PascalCaseElement Plus icons 使 PascalCase
const pascalName = iconName.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join('')
return ElementPlusIconsVue[pascalName] || null
}
}
return null
})
</script>
<style scoped>
.sub-el-icon {
color: currentColor;
width: 1em;
height: 1em;
}
</style>

@ -0,0 +1,42 @@
<template>
<component :is="type" v-bind="linkProps(to)">
<slot />
</component>
</template>
<script setup>
import { computed } from 'vue'
import { isExternal } from '@/utils/validate'
const props = defineProps({
to: {
type: String,
required: true
}
})
const isExternalLink = computed(() => {
return isExternal(props.to)
})
const type = computed(() => {
if (isExternalLink.value) {
return 'a'
}
return 'router-link'
})
const linkProps = (to) => {
if (isExternalLink.value) {
return {
href: to,
target: '_blank',
rel: 'noopener'
}
}
return {
to: to
}
}
</script>

@ -0,0 +1,166 @@
<template>
<div v-if="!item.hidden">
<!-- 如果有子项显示为子菜单即使只有一个子项也显示父级菜单 -->
<el-sub-menu v-if="item.children && item.children.length > 0" :index="getSubmenuIndex()" popper-append-to-body>
<template #title>
<menu-item :icon="item.meta?.icon || item.icon" :title="item.meta?.title || item.title || item.name" :is-collapse="isCollapse" />
</template>
<sidebar-item
v-for="child in item.children"
:key="child.key || child.path || String(child.id)"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
:is-collapse="isCollapse"
class="nest-menu"
/>
</el-sub-menu>
<!-- 如果没有子项显示为普通菜单项 -->
<app-link v-else-if="item.meta" :to="resolvePath(item.path)">
<el-menu-item :index="resolvePath(item.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
<menu-item :icon="item.meta.icon" :title="item.meta.title" :is-collapse="isCollapse" />
</el-menu-item>
</app-link>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { isExternal } from '@/utils/validate'
import MenuItem from './Item.vue'
import AppLink from './Link.vue'
const props = defineProps({
// route object
item: {
type: Object,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
},
isCollapse: {
type: Boolean,
default: false
}
})
//
const onlyOneChild = ref(null)
/**
* 判断是否只有一个可见子项
* 与参考项目保持一致
*/
const hasOneShowingChild = (children = [], parent) => {
const showingChildren = children.filter(item => {
if (item.hidden) {
return false
} else {
// 使
onlyOneChild.value = item
return true
}
})
//
if (showingChildren.length === 1) {
return true
}
//
if (showingChildren.length === 0) {
onlyOneChild.value = { ...parent, path: '', noShowingChildren: true }
return true
}
// false
return false
}
/**
* 获取子菜单的 index用于 el-submenu
*/
const getSubmenuIndex = () => {
// 使 key path id
if (props.item.key) {
return props.item.key
}
if (props.item.path) {
return props.item.path
}
return String(props.item.id || 'submenu')
}
/**
* 简单的路径解析函数替代 path.resolve
* 行为与 Node.js path.resolve 类似但适用于浏览器环境
*
* path.resolve 的行为
* - path.resolve('', 'a') 返回当前工作目录 + '/a'但在浏览器中我们简化为 '/a'
* - path.resolve('/a', 'b') 返回 '/a/b'
* - path.resolve('/a', '/b') 返回 '/b'绝对路径优先
*/
const pathResolve = (basePath, routePath) => {
// routePath /
if (routePath && routePath.startsWith('/')) {
return routePath
}
// basePath routePath /
// basePath routePath /
if (!basePath || basePath === '') {
return routePath ? `/${routePath}` : '/'
}
// basePath /
const base = basePath.startsWith('/') ? basePath : `/${basePath}`
// basePath /
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base
//
if (!routePath || routePath === '') {
return normalizedBase
}
return `${normalizedBase}/${routePath}`
}
/**
* 解析路由路径与参考项目保持一致
* 使用 Node.js path.resolve 的行为
*/
const resolvePath = (routePath) => {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(props.basePath)) {
return props.basePath
}
// routePath /
if (routePath && routePath.startsWith('/')) {
return routePath
}
// basePath routePath
if (props.basePath && routePath && props.basePath === routePath) {
return routePath.startsWith('/') ? routePath : `/${routePath}`
}
// 使 pathResolve Node.js path.resolve
return pathResolve(props.basePath, routePath)
}
</script>
<style scoped>
/* 嵌套菜单样式 */
</style>

@ -0,0 +1,173 @@
<template>
<aside class="sidebar" :class="{ collapse: isCollapse }">
<el-menu
:default-active="activeMenu"
:default-openeds="defaultOpeneds"
class="nav-menu"
router
:collapse="isCollapse"
:collapse-transition="false"
>
<sidebar-item
v-for="route in permissionRoutes"
:key="route.key || route.path || route.name || String(route.id || Math.random())"
:item="route"
:base-path="''"
:is-collapse="isCollapse"
/>
</el-menu>
<div class="sidebar-toggle" @click="toggleCollapse">
<el-icon><Fold v-if="!isCollapse" /><Expand v-else /></el-icon>
</div>
</aside>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePermissionStore } from '@/store/permission'
import { Fold, Expand } from '@element-plus/icons-vue'
import SidebarItem from './SidebarItem.vue'
const route = useRoute()
const router = useRouter()
const permissionStore = usePermissionStore()
const isCollapse = ref(false)
//
const defaultOpeneds = computed(() => {
return []
})
//
// 使 permission_routes children
const permissionRoutes = computed(() => {
//
// permissionStore.permissionRoutes 404
const dynamicRoutes = permissionStore.permissionRoutes.filter(route => !route.hidden && !route.meta?.hidden)
// router Layout dashboard
const layoutRoute = router.getRoutes().find(r => r.name === 'Layout')
const constantChildren = (layoutRoute?.children || []).filter(route => !route.hidden && !route.meta?.hidden)
//
// path children
const allRoutes = [...constantChildren, ...dynamicRoutes]
//
console.log('[Sidebar] 动态路由数量:', dynamicRoutes.length)
console.log('[Sidebar] 动态路由详情:', dynamicRoutes)
console.log('[Sidebar] 常量路由数量:', constantChildren.length)
console.log('[Sidebar] 最终菜单路由数量:', allRoutes.length)
return allRoutes
})
//
const activeMenu = computed(() => {
const { meta, path } = route
// activeMenu使
if (meta?.activeMenu) {
return meta.activeMenu
}
return path
})
//
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
}
</script>
<style scoped>
.sidebar {
width: 260px;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05);
border-right: 1px solid #f5f7fa;
display: flex;
flex-direction: column;
transition: width 0.3s;
height: 100%;
overflow: hidden;
}
.sidebar.collapse {
width: 64px;
}
.nav-menu {
flex: 1;
border-right: none;
overflow-y: auto;
overflow-x: hidden;
}
/* 移除子菜单标题的下划线 */
.nav-menu :deep(.el-sub-menu__title) {
text-decoration: none !important;
}
.nav-menu :deep(.el-sub-menu__title:hover) {
text-decoration: none !important;
}
/* 移除 el-menu 下所有 a 标签的下划线 */
.nav-menu :deep(a) {
text-decoration: none !important;
}
.nav-menu :deep(a:hover) {
text-decoration: none !important;
}
/* 自定义滚动条样式 */
.nav-menu::-webkit-scrollbar {
width: 6px;
}
.nav-menu::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
.nav-menu::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 3px;
}
.nav-menu::-webkit-scrollbar-thumb:hover {
background: #a0a4ac;
}
.sidebar-toggle {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-top: 1px solid #e4e7ed;
transition: background-color 0.3s;
}
.sidebar-toggle:hover {
background-color: #f5f7fa;
}
/* 响应式 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -260px;
z-index: 1000;
height: calc(100vh - 60px);
}
.sidebar:not(.collapse) {
left: 0;
}
}
</style>

@ -2,186 +2,58 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import { getToken, setToken } from '@/utils/auth'
import Login from '@/views/Login.vue'
import MainLayout from '@/layout/MainLayout.vue'
import Dashboard from '@/views/Dashboard.vue'
import BudgetList from '@/views/BudgetList.vue'
import ExecutionList from '@/views/ExecutionList.vue'
import Report from '@/views/Report.vue'
// 事前流程
import StartProcess from '@/views/pre-approval/StartProcess.vue'
import ProcessQuery from '@/views/pre-approval/ProcessQuery.vue'
// 支付流程
import DirectPayment from '@/views/payment/DirectPayment.vue'
import IndirectPayment from '@/views/payment/IndirectPayment.vue'
import PaymentProcessQuery from '@/views/payment/ProcessQuery.vue'
import PaymentQuery from '@/views/payment/PaymentQuery.vue'
import PaymentDetailPrint from '@/views/payment/PaymentDetailPrint.vue'
import DraftQuery from '@/views/payment/DraftQuery.vue'
import CreatePayment from '@/views/payment/CreatePayment.vue'
import ContractManagement from '@/views/payment/ContractManagement.vue'
import ContractSettings from '@/views/payment/ContractSettings.vue'
// 资金管理
import Budget from '@/views/funds/Budget.vue'
import BudgetManagement from '@/views/funds/BudgetManagement.vue'
// 系统设置
import CanvasSettings from '@/views/settings/CanvasSettings.vue'
import PlannedExpenditureCategory from '@/views/settings/PlannedExpenditureCategory.vue'
import PaymentCategory from '@/views/settings/PaymentCategory.vue'
import TemplateElementSettings from '@/views/settings/TemplateElementSettings.vue'
import PaymentTemplateElementSettings from '@/views/settings/PaymentTemplateElementSettings.vue'
import PreApprovalTemplateSettings from '@/views/settings/PreApprovalTemplateSettings.vue'
import PreApprovalProcessConfig from '@/views/settings/PreApprovalProcessConfig.vue'
const routes = [
/**
* 常量路由无需权限所有角色可访问
*/
export const constantRoutes = [
{
path: '/login',
name: 'Login',
component: Login
component: Login,
hidden: true
},
{
path: '/404',
name: '404',
component: () => import('@/views/404.vue'),
hidden: true
},
{
path: '/',
name: 'Layout',
component: MainLayout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: Dashboard
},
// 事前流程
{
path: 'pre-approval/start-process',
name: 'StartProcess',
component: StartProcess
},
{
path: 'pre-approval/process-query',
name: 'ProcessQuery',
component: ProcessQuery
},
// 支付流程
{
path: 'payment/direct-payment',
name: 'DirectPayment',
component: DirectPayment
},
{
path: 'payment/indirect-payment',
name: 'IndirectPayment',
component: IndirectPayment
},
{
path: 'payment/process-query',
name: 'PaymentProcessQuery',
component: PaymentProcessQuery
},
{
path: 'payment/payment-query',
name: 'PaymentQuery',
component: PaymentQuery
},
{
path: 'payment/payment-detail-print/:id',
name: 'PaymentDetailPrint',
component: PaymentDetailPrint
},
{
path: 'payment/draft-query',
name: 'DraftQuery',
component: DraftQuery
},
{
path: 'payment/create-payment',
name: 'CreatePayment',
component: CreatePayment
},
{
path: 'payment/contract-management',
name: 'ContractManagement',
component: ContractManagement
},
{
path: 'settings/contract-settings',
name: 'ContractSettings',
component: ContractSettings
},
// 资金管理
{
path: 'funds/budget',
name: 'Budget',
component: Budget
},
{
path: 'funds/budget-management',
name: 'BudgetManagement',
component: BudgetManagement
},
// 系统设置
{
path: 'settings/planned-expenditure-template-settings',
name: 'CanvasSettings',
component: CanvasSettings
},
{
path: 'settings/planned-expenditure-category',
name: 'PlannedExpenditureCategory',
component: PlannedExpenditureCategory
},
{
path: 'settings/payment-category',
name: 'PaymentCategory',
component: PaymentCategory
},
{
path: 'settings/template-element-settings',
name: 'TemplateElementSettings',
component: TemplateElementSettings
},
{
path: 'settings/payment-template-element-settings',
name: 'PaymentTemplateElementSettings',
component: PaymentTemplateElementSettings
},
{
path: 'settings/pre-approval-template-settings',
name: 'PreApprovalTemplateSettings',
component: PreApprovalTemplateSettings
},
{
path: 'settings/pre-approval-process-config',
name: 'PreApprovalProcessConfig',
component: PreApprovalProcessConfig
},
// 保留原有路由
{
path: 'budget-list',
name: 'BudgetList',
component: BudgetList
},
{
path: 'execution-list',
name: 'ExecutionList',
component: ExecutionList
},
{
path: 'report',
name: 'Report',
component: Report
component: () => import('@/views/Dashboard.vue'),
meta: {
title: '首页',
icon: 'el-icon-odometer'
}
}
]
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
/**
* 异步路由需权限动态加载
*/
export const asyncRoutes = []
const createRouterInstance = () => {
return createRouter({
history: createWebHashHistory(),
routes: constantRoutes
})
}
const router = createRouterInstance()
// 路由守卫 - 处理跨模块认证和登录状态检查
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
try {
// 1. 处理URL参数中的auth_token跨模块跳转时携带
if (to.query.auth_token) {
@ -202,11 +74,86 @@ router.beforeEach((to, from, next) => {
: '/'
next({ path: redirectPath, replace: true })
} else {
// 支持to参数进行路由跳转跨模块跳转时使用
if (to.query.to && /^\/.*/.test(to.query.to)) {
next({ path: to.query.to, replace: true })
// 动态加载路由
const { usePermissionStore } = await import('@/store/permission')
const permissionStore = usePermissionStore()
// 如果还没有生成路由,则生成
if (permissionStore.addRoutes.length === 0) {
try {
const accessedRoutes = await permissionStore.generateRoutes()
console.log('[Router] 获取到的路由:', accessedRoutes)
// 过滤出 404 路由和其他路由
const notFoundRoute = accessedRoutes.find(route => route.path === '/:pathMatch(.*)*')
/**
* 扁平化路由树将一级菜单的 children 提取出来作为独立路由
* 因为 Vue Router 4 不能注册 path 为空的路由但我们需要保留一级菜单结构用于菜单显示
*/
const flattenRoutes = (routes) => {
const flattened = []
routes.forEach(route => {
// 跳过 404 路由
if (route.path === '/:pathMatch(.*)*') {
return
}
// 如果路由有 children 且 path 为空(文件夹路由),提取其 children
if (route.children && route.children.length > 0 && (!route.path || route.path === '')) {
// 递归处理 children将它们扁平化
const childRoutes = flattenRoutes(route.children)
flattened.push(...childRoutes)
} else if (route.path && route.path !== '') {
// 如果路由有实际的 path直接添加但需要移除 children因为已经扁平化了
const { children, ...routeWithoutChildren } = route
flattened.push(routeWithoutChildren)
}
})
return flattened
}
const dynamicRoutes = flattenRoutes(accessedRoutes)
console.log('[Router] 动态路由数量:', dynamicRoutes.length)
console.log('[Router] 动态路由详情:', dynamicRoutes)
// 将动态路由作为主布局name: 'Layout')的子路由添加
dynamicRoutes.forEach((route, index) => {
console.log(`[Router] 添加路由 ${index + 1}:`, route.path, route.name)
router.addRoute('Layout', route)
})
// 最后添加 404 路由(放在最后匹配)
if (notFoundRoute) {
router.addRoute(notFoundRoute)
}
// 打印所有路由用于调试
console.log('[Router] 当前所有路由:', router.getRoutes())
// 重新导航到目标路由,确保路由已添加
// 如果访问的是根路径,重定向到 dashboard
if (to.path === '/' || to.path === '') {
console.log('[Router] 重定向到 /dashboard')
next({ path: '/dashboard', replace: true })
} else {
console.log('[Router] 导航到:', to.path)
next({ ...to, replace: true })
}
} catch (error) {
console.error('[Router] 生成动态路由失败:', error)
console.error('[Router] 错误详情:', error)
// 如果生成路由失败,仍然允许访问
next()
}
} else {
next()
// 支持to参数进行路由跳转跨模块跳转时使用
if (to.query.to && /^\/.*/.test(to.query.to)) {
next({ path: to.query.to, replace: true })
} else {
next()
}
}
}
} else {
@ -234,5 +181,13 @@ router.beforeEach((to, from, next) => {
}
})
/**
* 重置路由
*/
export function resetRouter() {
const newRouter = createRouterInstance()
router.matcher = newRouter.matcher
}
export default router

@ -0,0 +1,214 @@
import { defineStore } from 'pinia'
import { permissionAPI } from '@/utils/api'
import MainLayout from '@/layout/MainLayout.vue'
/**
* 使用 import.meta.glob 预加载所有视图组件
* Vite 的限制动态 import() 中的变量路径只能表示单层深度
* 因此使用 import.meta.glob 预加载所有视图文件
*
* import.meta.glob 返回的对象键的格式可能是 '@/views/...' '/src/views/...'
*/
const views = import.meta.glob('@/views/**/*.vue', { eager: false })
// 在首次加载时打印所有可用的路径(用于调试)
if (process.env.NODE_ENV === 'development') {
console.log('[Permission Store] 预加载的视图组件路径:', Object.keys(views))
}
/**
* 动态加载视图组件
*/
const loadView = (view) => {
// view 应该是类似 /pre-approval/StartProcess 这样的路径
console.log('[Permission Store] 加载视图:', view)
// 确保路径以 / 开头
const viewPath = view.startsWith('/') ? view : `/${view}`
// 尝试多种路径格式来匹配
const possiblePaths = [
`@/views${viewPath}.vue`, // @/views/pre-approval/StartProcess.vue
`/src/views${viewPath}.vue`, // /src/views/pre-approval/StartProcess.vue
`./src/views${viewPath}.vue`, // ./src/views/pre-approval/StartProcess.vue
`../src/views${viewPath}.vue`, // ../src/views/pre-approval/StartProcess.vue
]
// 从预加载的 views 对象中查找对应的组件
for (const fullPath of possiblePaths) {
if (views[fullPath]) {
console.log('[Permission Store] 找到视图组件:', fullPath)
return views[fullPath]
}
}
// 如果所有格式都找不到,尝试模糊匹配(根据文件名)
const fileName = viewPath.split('/').pop()
const matchedKey = Object.keys(views).find(key => key.includes(`/${fileName}.vue`))
if (matchedKey) {
console.log('[Permission Store] 通过模糊匹配找到视图组件:', matchedKey)
return views[matchedKey]
}
console.warn('[Permission Store] 找不到视图组件:', viewPath, '尝试的路径:', possiblePaths)
console.warn('[Permission Store] 可用的路径:', Object.keys(views))
return null
}
/**
* 处理路由组件
*/
const componentHandle = (url, route) => {
// 如果 path 以 # 开头且 pid === 0使用 MainLayout
if (/^#+/.test(route.path) && route.pid === 0) {
return MainLayout
} else if (/^#+/.test(route.path) && route.pid !== 0) {
// 嵌套布局(如果需要的话,可以创建 NestedLayout
return MainLayout
} else {
// 处理 url 路径
let viewPath = url
if (!viewPath) {
console.warn('[Permission Store] 路由缺少 url 字段:', route)
return null
}
// 如果 url 是 #,说明这是一个文件夹,不需要组件
if (viewPath === '#') {
return null
}
// 如果 url 已经包含 @/views去掉这个前缀因为 loadView 会自动添加)
if (viewPath.startsWith('@/views')) {
viewPath = viewPath.replace('@/views', '')
}
// 如果 url 不是以 / 开头,添加 /
if (!viewPath.startsWith('/')) {
viewPath = '/' + viewPath
}
console.log('[Permission Store] 处理后的 viewPath:', viewPath, '原始 url:', url)
return loadView(viewPath)
}
}
/**
* 过滤和转换异步路由
*/
export function filterAsyncRoutes(routes) {
const res = []
routes.forEach(route => {
// 过滤不可见的路由
if (!route.visible) return
// 解析路径参数
const params = {}
if (route.path?.includes('?')) {
const flag = route.path.split('?')
route.path = flag[0]
if (flag[1]) {
const list = flag[1].split('&')
list.forEach(item => {
const kv = item.split('=')
if (kv.length === 2) {
params[kv[0]] = kv[1]
}
})
}
}
// 处理路径:如果 path 是 #,则设为空字符串(作为主布局的子路由)
// 保持路径原样,不去掉前导 /(与参考项目一致)
let routePath = route.path === '#' ? '' : route.path
// 如果 path 是 # 且 folder 是 true这是一个文件夹菜单不需要 component
const isFolder = route.path === '#' && route.folder
const tmp = {
key: `key-${route.id}`,
path: routePath, // 保持为空字符串,不要使用 folder-${route.id}
component: isFolder ? null : componentHandle(route.url, route), // 文件夹不需要 component
name: route.name,
hidden: !route.visible,
meta: {
title: route.title,
icon: route.icon,
guard: route.guard_name,
folder: route.folder,
params
}
}
// 递归处理子路由
if (route.children && route.children instanceof Array && route.children.length > 0) {
tmp.children = filterAsyncRoutes(route.children)
}
res.push(tmp)
})
return res
}
export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: [],
addRoutes: []
}),
getters: {
permissionRoutes: (state) => state.routes,
addRoutesList: (state) => state.addRoutes
},
actions: {
/**
* 生成动态路由
*/
async generateRoutes() {
try {
// 调用 API 获取权限路由数据
const response = await permissionAPI.getPermissions()
console.log('[Permission Store] API 返回数据:', response)
// 处理路由数据
let accessedRoutes = []
if (response.code === 0 && response.data) {
accessedRoutes = filterAsyncRoutes(response.data)
console.log('[Permission Store] 处理后的路由:', accessedRoutes)
} else {
console.warn('[Permission Store] API 返回数据格式不正确:', response)
}
// 添加 404 路由(放在最后匹配)
accessedRoutes.push({
path: '/:pathMatch(.*)*',
redirect: '/404',
hidden: true
})
// 保存路由
this.addRoutes = accessedRoutes
// 保存完整的路由结构(包括一级菜单),用于菜单显示
// 注意:这里保存的是 filterAsyncRoutes 返回的完整路由树包括一级菜单path 为空,有 children
this.routes = accessedRoutes.filter(route => route.path !== '/:pathMatch(.*)*')
console.log('[Permission Store] 最终路由列表(用于菜单显示):', this.routes)
return Promise.resolve(accessedRoutes)
} catch (err) {
console.error('[Permission Store] 生成动态路由失败:', err)
return Promise.reject(err)
}
},
/**
* 重置路由
*/
resetRoutes() {
this.routes = []
this.addRoutes = []
}
}
})

@ -150,8 +150,6 @@ export const reportAPI = {
}
}
// 分支条件相关 API 已移除
/**
* 计划支出分类相关 API
*/
@ -181,11 +179,6 @@ export const plannedExpenditureCategoryAPI = {
return request.get('/budget/planned-expenditure-categories-tree')
},
// 获取分类详情
getDetail: (id) => {
return request.get(`/budget/planned-expenditure-categories/${id}`)
},
// 创建分类
create: (data) => {
return request.post('/budget/planned-expenditure-categories', data)
@ -204,9 +197,7 @@ export const plannedExpenditureCategoryAPI = {
// 批量删除分类
batchDelete: (ids) => {
return request.post('/budget/planned-expenditure-categories/batch-destroy', { ids })
},
// 获取分类的分支条件详情 - 已移除,使用分类层级关系替代
}
}
/**
@ -677,7 +668,6 @@ export const expenditureQueryAPI = {
}
}
/**
* 认证相关 API
* 注意baseURL 已配置为 /api所以这里不需要再加 /api 前缀
@ -702,31 +692,25 @@ export const authAPI = {
}
}
// 导出便捷方法(保持向后兼容)
export const login = authAPI.login
export const getInfo = authAPI.getInfo
export const logout = authAPI.logout
/**
* 使用示例
*
* import { budgetAPI } from '@/utils/api'
*
* // 在组件中使用
* const fetchBudgetList = async () => {
* try {
* const data = await budgetAPI.getBudgetList({ page: 1, pageSize: 10 })
* console.log(data)
* } catch (error) {
* console.error('获取预算列表失败:', error)
* }
* }
*
* 注意
* - 所有 API 请求都会自动使用 config.api.baseURL 作为基础地址
* - 开发环境下如果启用了调试模式会在控制台打印请求和响应信息
* - 请求超时时间由 config.api.timeout 控制
* 权限相关 API
*/
export const permissionAPI = {
// 获取权限路由数据
getPermissions: () => {
// 获取模块名称优先级window.MODULE_NAME > 从路径提取 > 环境变量
const moduleName = window.MODULE_NAME ||
window.location.pathname.replaceAll(/\//g, '') ||
import.meta.env.VITE_APP_MODULE_NAME ||
'budget'
return request.get(`/auth/module-permissions/${moduleName}`, {
module: moduleName
}, {
isLoading: false
})
}
}
/**
* OA会议纪要相关 API
@ -743,3 +727,7 @@ export const oaMeetingMinutesAPI = {
}
}
// 导出便捷方法(保持向后兼容)
export const login = authAPI.login
export const getInfo = authAPI.getInfo
export const logout = authAPI.logout

@ -0,0 +1,23 @@
/**
* 验证工具函数
*/
/**
* 判断是否为外部链接
* @param {string} path - 路径
* @returns {Boolean}
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}
/**
* 验证用户名可选保留向后兼容
* @param {string} str - 用户名
* @returns {Boolean}
*/
export function validUsername(str) {
const valid_map = ['admin', 'editor']
return valid_map.indexOf(str.trim()) >= 0
}

@ -0,0 +1,242 @@
<template>
<div class="wscn-http404-container">
<div class="wscn-http404">
<div class="pic-404">
<img class="pic-404__parent" src="@/assets/404_images/404.png" alt="404">
<img class="pic-404__child left" src="@/assets/404_images/404_cloud.png" alt="404">
<img class="pic-404__child mid" src="@/assets/404_images/404_cloud.png" alt="404">
<img class="pic-404__child right" src="@/assets/404_images/404_cloud.png" alt="404">
</div>
<div class="bullshit">
<div class="bullshit__oops">OOPS!</div>
<div class="bullshit__headline">{{ message }}</div>
<div class="bullshit__info">请检查您输入的网址是否正确或者单击下面的按钮返回主页</div>
<a href="" class="bullshit__return-home">返回首页</a>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Page404',
computed: {
message() {
return '无法进入这个页面...'
}
}
}
</script>
<style scoped>
.wscn-http404-container {
transform: translate(-50%, -50%);
position: absolute;
top: 40%;
left: 50%;
}
.wscn-http404 {
position: relative;
width: 1200px;
padding: 0 50px;
overflow: hidden;
}
.pic-404 {
position: relative;
float: left;
width: 600px;
overflow: hidden;
}
.pic-404__parent {
width: 100%;
}
.pic-404__child {
position: absolute;
}
.pic-404__child.left {
width: 80px;
top: 17px;
left: 220px;
opacity: 0;
animation-name: cloudLeft;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
.pic-404__child.mid {
width: 46px;
top: 10px;
left: 420px;
opacity: 0;
animation-name: cloudMid;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1.2s;
}
.pic-404__child.right {
width: 62px;
top: 100px;
left: 500px;
opacity: 0;
animation-name: cloudRight;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
@keyframes cloudLeft {
0% {
top: 17px;
left: 220px;
opacity: 0;
}
20% {
top: 33px;
left: 188px;
opacity: 1;
}
80% {
top: 81px;
left: 92px;
opacity: 1;
}
100% {
top: 97px;
left: 60px;
opacity: 0;
}
}
@keyframes cloudMid {
0% {
top: 10px;
left: 420px;
opacity: 0;
}
20% {
top: 40px;
left: 360px;
opacity: 1;
}
70% {
top: 130px;
left: 180px;
opacity: 1;
}
100% {
top: 160px;
left: 120px;
opacity: 0;
}
}
@keyframes cloudRight {
0% {
top: 100px;
left: 500px;
opacity: 0;
}
20% {
top: 120px;
left: 460px;
opacity: 1;
}
80% {
top: 180px;
left: 340px;
opacity: 1;
}
100% {
top: 200px;
left: 300px;
opacity: 0;
}
}
.bullshit {
position: relative;
float: left;
width: 300px;
padding: 30px 0;
overflow: hidden;
}
.bullshit__oops {
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
opacity: 0;
margin-bottom: 20px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-fill-mode: forwards;
}
.bullshit__headline {
font-size: 20px;
line-height: 24px;
color: #222;
font-weight: bold;
opacity: 0;
margin-bottom: 10px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.1s;
animation-fill-mode: forwards;
}
.bullshit__info {
font-size: 13px;
line-height: 21px;
color: grey;
opacity: 0;
margin-bottom: 30px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.2s;
animation-fill-mode: forwards;
}
.bullshit__return-home {
display: block;
float: left;
width: 110px;
height: 36px;
background: #1482f0;
border-radius: 100px;
text-align: center;
color: #ffffff;
opacity: 0;
font-size: 14px;
line-height: 36px;
cursor: pointer;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.3s;
animation-fill-mode: forwards;
}
@keyframes slideUp {
0% {
transform: translateY(60px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
</style>
Loading…
Cancel
Save