侧边栏支持按权限隐藏菜单(Sidebar/index.vue): - 新增 canSee():读取 meta.permissions(数组,满足任一即可) - ★ 同时修了一个既有缺陷:原实现只过滤顶层路由,**子路由的权限判定不生效**, 未授权用户仍能在菜单里看到入口。现改为递归过滤,父菜单的子项被过滤光后 父菜单本身也不再显示。 - ★ 刻意只认 meta.permissions(新键),历史遗留的 meta.permission(单数, 如「维修管理」的 inbound_repair)保持不生效——一旦开始消费它,会让若干 菜单对部分角色突然消失,属于超出本次范围的静默行为变更。两者若要统一, 建议单独排期逐个核对影响面。 路由守卫(router/index.ts): - 新增 meta.permissions 校验,未授权时提示并跳首页。侧边栏负责「菜单看不见」, 守卫负责「直接敲 URL 也进不去」,后端负责「绕过前端直接调接口也不行」, 三层任一失效都不至于越权。 - 顺带修了原守卫的分支结构:roles 通过时直接 next(),会跳过后续检查; 改为不通过才中断,落到末尾统一 next()。 新增「退回记录」页(views/outbound/returns/index.vue): - 纯只读台账,无任何操作按钮,供库管核对历史退回 - 展示 退回时间 / 原出库单号 / 物料名称 / 规格 / SKU / 退回数量 / 退回类型 / 退回原因 / 操作人 - 退回类型用标签区分(不良品=红,良品=绿) - 源库存行或原单被删除时显示占位文案而非空白,台账不留哑行 api/inbound/return.ts 补 getReturnList()。
451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import { createRouter, createWebHistory } from 'vue-router'
|
||
import type { RouteRecordRaw } from 'vue-router'
|
||
import Layout from '@/layout/index.vue'
|
||
import { useUserStore } from '@/stores/user'
|
||
import BomManage from '@/views/bom/BomManage.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
// [新增] 扩展 RouteMeta 类型定义,防止 TS 报错
|
||
declare module 'vue-router' {
|
||
interface RouteMeta {
|
||
title?: string
|
||
icon?: string
|
||
hidden?: boolean
|
||
roles?: string[] // 允许的角色列表
|
||
}
|
||
}
|
||
|
||
const routes: Array<RouteRecordRaw> = [
|
||
// 1. 登录页
|
||
{
|
||
path: '/login',
|
||
name: 'Login',
|
||
component: () => import('@/views/login/index.vue'),
|
||
meta: { hidden: true }
|
||
},
|
||
|
||
// 2. 首页 Dashboard
|
||
{
|
||
path: '/',
|
||
component: Layout,
|
||
redirect: '/dashboard',
|
||
children: [
|
||
{
|
||
path: 'dashboard',
|
||
name: 'Dashboard',
|
||
component: () => import('@/views/dashboard/index.vue'),
|
||
meta: { title: '首页', icon: 'HomeFilled' }
|
||
}
|
||
]
|
||
},
|
||
|
||
// 3. 物料管理
|
||
{
|
||
path: '/material',
|
||
component: Layout,
|
||
redirect: '/material/index',
|
||
meta: { title: '物料管理', icon: 'Box' },
|
||
children: [
|
||
{
|
||
path: 'index',
|
||
name: 'MaterialBase',
|
||
component: () => import('@/views/material/list.vue'),
|
||
meta: { title: '基础信息', icon: 'Box' }
|
||
},
|
||
{
|
||
path: 'buyOdoo',
|
||
name: 'BuyOdoo',
|
||
component: () => import('@/views/material/buyOdoo.vue'),
|
||
meta: { title: '基础信息(Odoo)', icon: 'Grid' }
|
||
}
|
||
]
|
||
},
|
||
|
||
// 4. 库存管理 (入库)
|
||
{
|
||
path: '/inventory',
|
||
component: Layout,
|
||
meta: { title: '入库管理', icon: 'Shop' },
|
||
redirect: '/inventory/buy',
|
||
children: [
|
||
{
|
||
path: 'buy',
|
||
name: 'InventoryBuy',
|
||
component: () => import('@/views/stock/inbound/buy.vue'),
|
||
meta: { title: '采购件' }
|
||
},
|
||
{
|
||
path: 'semi',
|
||
name: 'InventorySemi',
|
||
component: () => import('@/views/stock/inbound/semi.vue'),
|
||
meta: { title: '半成品' }
|
||
},
|
||
{
|
||
path: 'product',
|
||
name: 'InventoryProduct',
|
||
component: () => import('@/views/stock/inbound/product.vue'),
|
||
meta: { title: '成品' }
|
||
},
|
||
|
||
{
|
||
path: 'service',
|
||
name: 'InventoryService',
|
||
component: () => import('@/views/stock/inbound/service.vue'),
|
||
meta: { title: '服务权益' }
|
||
},
|
||
// [原有] 入库记录整合
|
||
{
|
||
path: 'summary',
|
||
name: 'InventorySummary',
|
||
component: () => import('@/views/stock/inbound/inbound_summary.vue'),
|
||
meta: { title: '入库记录' }
|
||
},
|
||
// 维修管理
|
||
{
|
||
path: 'repair',
|
||
name: 'RepairManagement',
|
||
component: () => import('@/views/stock/inbound/repair.vue'),
|
||
meta: { title: '维修管理', permission: 'inbound_repair' }
|
||
},
|
||
// ★ 不良品在管台账(逆向物流):退回的坏件不入库存表,
|
||
// 实物在仓但不在库存行中,故单独成页承载其去向与处置。
|
||
// meta.permissions 生效于 Sidebar(菜单隐藏)与路由守卫(直达 URL 拦截),
|
||
// 后端 list API 同名权限码校验,三处一致。
|
||
{
|
||
path: 'defective',
|
||
name: 'DefectiveGoods',
|
||
component: () => import('@/views/stock/defective/index.vue'),
|
||
meta: { title: '不良品在管台账', permissions: ['defective_list'] }
|
||
}
|
||
]
|
||
},
|
||
|
||
// 4.1 盘点管理 (独立顶级菜单)
|
||
{
|
||
path: '/stocktake',
|
||
component: Layout,
|
||
redirect: '/stocktake/operation',
|
||
meta: { title: '盘点管理', icon: 'DataBoard' },
|
||
children: [
|
||
{
|
||
path: 'operation',
|
||
name: 'InventoryStocktake',
|
||
component: () => import('@/views/stock/stocktake/index.vue'),
|
||
meta: { title: '盲盘作业' }
|
||
},
|
||
{
|
||
path: 'adjustment',
|
||
name: 'StockAdjustment',
|
||
component: () => import('@/views/stock/adjustment/index.vue'),
|
||
meta: { title: '盈亏调整' }
|
||
}
|
||
]
|
||
},
|
||
|
||
// 5. 出库管理
|
||
{
|
||
path: '/outbound',
|
||
component: Layout,
|
||
meta: { title: '出库管理', icon: 'Van' },
|
||
redirect: '/outbound/index',
|
||
children: [
|
||
// ★ [新增] 出库选单打印页面
|
||
{
|
||
path: 'selection',
|
||
name: 'OutboundSelection',
|
||
component: () => import('@/views/outbound/Selection.vue'),
|
||
meta: { title: '出库选单' }
|
||
},
|
||
{
|
||
path: 'create',
|
||
name: 'OutboundCreate',
|
||
component: () => import('@/views/outbound/create.vue'),
|
||
meta: { title: '扫码出库' }
|
||
},
|
||
{
|
||
path: 'index',
|
||
name: 'OutboundList',
|
||
component: () => import('@/views/outbound/index.vue'),
|
||
meta: { title: '出库记录' }
|
||
},
|
||
{
|
||
path: 'approval',
|
||
name: 'OutboundApproval',
|
||
component: () => import('@/views/outbound/approval/index.vue'),
|
||
meta: {
|
||
title: '出库审批',
|
||
icon: 'Stamp'
|
||
}
|
||
},
|
||
// ★ 退回记录(逆向物流只读台账)
|
||
// meta.permissions 由 Sidebar 过滤菜单 + 路由守卫拦截直达 URL,
|
||
// 后端同名权限码校验,三处一致。权限码注册见
|
||
// db_migrations/add_return_view_support.sql
|
||
{
|
||
path: 'returns',
|
||
name: 'OutboundReturns',
|
||
component: () => import('@/views/outbound/returns/index.vue'),
|
||
meta: {
|
||
title: '退回记录',
|
||
permissions: ['outbound_return_list']
|
||
}
|
||
}
|
||
]
|
||
},
|
||
|
||
// 5. BOM 管理
|
||
{
|
||
path: '/bom',
|
||
component: Layout,
|
||
meta: { title: 'BOM管理', icon: 'Document' },
|
||
redirect: '/bom/manage',
|
||
children: [
|
||
{
|
||
path: 'manage',
|
||
name: 'BomManage',
|
||
component: BomManage,
|
||
meta: { title: 'BOM配方管理', icon: 'list' }
|
||
}
|
||
]
|
||
},
|
||
|
||
// 5.1 采购管理
|
||
{
|
||
path: '/purchase',
|
||
component: Layout,
|
||
meta: { title: '采购管理', icon: 'ShoppingCart' },
|
||
children: [
|
||
{
|
||
path: '',
|
||
name: 'PurchaseList',
|
||
component: () => import('@/views/purchase/index.vue'),
|
||
meta: { title: '采购申请' }
|
||
}
|
||
]
|
||
},
|
||
|
||
// 6.0 我的申请单(申请人视角,跨模块聚合)
|
||
//
|
||
// ★ 独立模块而非挂在某个业务页下:申请人可能提交出库/借库/报废三类单据,
|
||
// 放在任一业务模块下都会让其它模块的人找不到。
|
||
// 本页数据来自 /my-requests 聚合端点(仅需登录态,服务端硬编码
|
||
// applicant_id=当前用户),因此不需要也不应配置审批类权限码。
|
||
{
|
||
path: '/my-requests',
|
||
component: Layout,
|
||
children: [
|
||
{
|
||
path: '',
|
||
name: 'MyRequests',
|
||
component: () => import('@/views/my-requests/index.vue'),
|
||
meta: { title: '我的申请单', icon: 'Tickets' }
|
||
}
|
||
]
|
||
},
|
||
|
||
// 6. 借库管理
|
||
{
|
||
path: '/operation',
|
||
component: Layout,
|
||
meta: { title: '借库管理', icon: 'Operation' },
|
||
redirect: '/operation/borrow',
|
||
children: [
|
||
{
|
||
path: 'borrow_apply',
|
||
name: 'BorrowApply',
|
||
component: () => import('@/views/borrow/apply/index.vue'),
|
||
meta: { title: '借库选单' }
|
||
},
|
||
{
|
||
path: 'borrow',
|
||
name: 'OpBorrow',
|
||
component: () => import('@/views/transaction/borrow.vue'),
|
||
meta: { title: '扫码借库' }
|
||
},
|
||
{
|
||
path: 'repair',
|
||
name: 'OpRepair',
|
||
component: () => import('@/views/transaction/return.vue'),
|
||
meta: { title: '返还' }
|
||
},
|
||
{
|
||
path: 'records',
|
||
name: 'OpRecords',
|
||
component: () => import('@/views/transaction/records.vue'),
|
||
meta: { title: '借还记录' }
|
||
},
|
||
{
|
||
path: 'borrow_approval',
|
||
name: 'BorrowApproval',
|
||
component: () => import('@/views/borrow/approval/index.vue'),
|
||
meta: {
|
||
title: '借库审批',
|
||
icon: 'Stamp'
|
||
}
|
||
}
|
||
]
|
||
},
|
||
|
||
// 6.1 报废管理 (独立一级菜单)
|
||
{
|
||
path: '/scrap',
|
||
component: Layout,
|
||
meta: { title: '报废管理', icon: 'Delete' },
|
||
redirect: '/scrap/index',
|
||
children: [
|
||
{
|
||
path: 'apply',
|
||
name: 'ScrapApply',
|
||
component: () => import('@/views/operation/scrap/apply/index.vue'),
|
||
meta: { title: '报废申请' }
|
||
},
|
||
{
|
||
path: 'create',
|
||
name: 'ScrapCreate',
|
||
component: () => import('@/views/operation/scrap/create.vue'),
|
||
meta: { title: '按单报废' }
|
||
},
|
||
{
|
||
path: 'index',
|
||
name: 'ScrapList',
|
||
component: () => import('@/views/operation/scrap/index.vue'),
|
||
meta: { title: '报废记录' }
|
||
},
|
||
{
|
||
path: 'approval',
|
||
name: 'ScrapApproval',
|
||
component: () => import('@/views/operation/scrap/approval/index.vue'),
|
||
meta: { title: '报废审批' }
|
||
}
|
||
]
|
||
},
|
||
|
||
// 7. 系统管理
|
||
{
|
||
path: '/system',
|
||
component: Layout,
|
||
// [修复] 添加 redirect,点击父菜单时跳转到子页面
|
||
redirect: '/system/user-create',
|
||
meta: {
|
||
title: '系统管理',
|
||
icon: 'Setting'
|
||
},
|
||
children: [
|
||
{
|
||
path: 'user-create',
|
||
name: 'UserCreate',
|
||
component: () => import('@/views/system/UserCreate.vue'),
|
||
meta: {
|
||
title: '账号开通',
|
||
icon: 'User'
|
||
}
|
||
},
|
||
// [新增] 权限分配页面,只有超级管理员可进
|
||
{
|
||
path: 'permission',
|
||
name: 'PermissionConfig',
|
||
component: () => import('@/views/system/PermissionConfig.vue'),
|
||
meta: {
|
||
title: '权限分配',
|
||
icon: 'Lock'
|
||
}
|
||
},
|
||
{
|
||
path: 'audit',
|
||
name: 'AuditLog',
|
||
component: () => import('@/views/system/AuditLog.vue'),
|
||
meta: {
|
||
title: '审计日志',
|
||
icon: 'Document'
|
||
}
|
||
}
|
||
]
|
||
},
|
||
|
||
// 404 路由
|
||
{
|
||
path: '/:pathMatch(.*)*',
|
||
redirect: '/dashboard',
|
||
meta: { hidden: true }
|
||
}
|
||
]
|
||
|
||
const router = createRouter({
|
||
history: createWebHistory(),
|
||
routes
|
||
})
|
||
|
||
// ==========================================
|
||
// 全局路由守卫
|
||
// ==========================================
|
||
router.beforeEach((to, from, next) => {
|
||
const userStore = useUserStore()
|
||
|
||
const token = userStore.token || localStorage.getItem('access_token') || localStorage.getItem('token')
|
||
|
||
// [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效
|
||
const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user'
|
||
const userRole = String(rawRole).toUpperCase()
|
||
|
||
// ============================================================
|
||
// 安全兜底:检查 refresh_token 是否即将过期(30分钟)
|
||
// ============================================================
|
||
if (token && userStore.isRefreshTokenExpiringSoon()) {
|
||
// 仅在用户主动操作时提示,避免页面加载就弹窗
|
||
const isUserAction = to.path !== '/login' && to.path !== '/'
|
||
if (isUserAction) {
|
||
ElMessage.warning('您的登录状态即将失效,请及时保存数据并重新登录')
|
||
}
|
||
}
|
||
|
||
// 调试日志
|
||
if (to.path.includes('/system')) {
|
||
console.log(`路由守卫检查: Path=${to.path}, UserRole=${userRole}, Required=${to.meta.roles}`)
|
||
}
|
||
|
||
if (to.path === '/login') {
|
||
if (token) {
|
||
next('/')
|
||
} else {
|
||
next()
|
||
}
|
||
return
|
||
}
|
||
|
||
if (!token) {
|
||
next({ path: '/login', replace: true })
|
||
return
|
||
}
|
||
|
||
// 权限检查逻辑
|
||
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||
if (!to.meta.roles.includes(userRole)) {
|
||
console.warn(`权限不足: 用户角色 ${userRole} 不在允许列表 ${to.meta.roles} 中`)
|
||
next('/dashboard')
|
||
return
|
||
}
|
||
}
|
||
|
||
// ★ 权限码检查(meta.permissions,满足任一即可)
|
||
//
|
||
// 与 Sidebar 的菜单过滤、后端 @permission_required 使用**同一批权限码**:
|
||
// · Sidebar 负责「菜单里看不见入口」
|
||
// · 这里负责「直接敲 URL 也进不去」
|
||
// · 后端负责「绕过前端直接调接口也不行」(最终防线)
|
||
// 三层任一失效都不至于越权。
|
||
if (Array.isArray(to.meta.permissions) && to.meta.permissions.length > 0) {
|
||
const allowed = to.meta.permissions.some(
|
||
(code: string) => userStore.hasPermission(code)
|
||
)
|
||
if (!allowed) {
|
||
console.warn(`权限不足: 缺少 ${to.meta.permissions.join(' / ')},已跳转首页`)
|
||
ElMessage.warning('您没有访问该页面的权限')
|
||
next('/dashboard')
|
||
return
|
||
}
|
||
}
|
||
|
||
next()
|
||
})
|
||
|
||
export default router |