@@ -151,35 +37,20 @@ @@ -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; - } -} diff --git a/src/layout/components/Sidebar/Item.vue b/src/layout/components/Sidebar/Item.vue new file mode 100644 index 0000000..29d3eae --- /dev/null +++ b/src/layout/components/Sidebar/Item.vue @@ -0,0 +1,68 @@ + + + + + + diff --git a/src/layout/components/Sidebar/Link.vue b/src/layout/components/Sidebar/Link.vue new file mode 100644 index 0000000..2a796b8 --- /dev/null +++ b/src/layout/components/Sidebar/Link.vue @@ -0,0 +1,42 @@ + + + + diff --git a/src/layout/components/Sidebar/SidebarItem.vue b/src/layout/components/Sidebar/SidebarItem.vue new file mode 100644 index 0000000..820970b --- /dev/null +++ b/src/layout/components/Sidebar/SidebarItem.vue @@ -0,0 +1,166 @@ + + + + + + diff --git a/src/layout/components/Sidebar/index.vue b/src/layout/components/Sidebar/index.vue new file mode 100644 index 0000000..2400813 --- /dev/null +++ b/src/layout/components/Sidebar/index.vue @@ -0,0 +1,173 @@ + + + + + + diff --git a/src/router/index.js b/src/router/index.js index 170105e..3b32773 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -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 diff --git a/src/store/permission.js b/src/store/permission.js new file mode 100644 index 0000000..658e6d7 --- /dev/null +++ b/src/store/permission.js @@ -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 = [] + } + } +}) + diff --git a/src/utils/api.js b/src/utils/api.js index f760d31..34e7a6e 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -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 diff --git a/src/utils/validate.js b/src/utils/validate.js new file mode 100644 index 0000000..a411e24 --- /dev/null +++ b/src/utils/validate.js @@ -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 +} + diff --git a/src/views/404.vue b/src/views/404.vue new file mode 100644 index 0000000..b56c53a --- /dev/null +++ b/src/views/404.vue @@ -0,0 +1,242 @@ + + + + + + \ No newline at end of file