Files
KCGL/inventory-web/src/router/index.ts
yueli 27436f5efe feat(ui): 菜单权限过滤与退回记录页
侧边栏支持按权限隐藏菜单(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()。
2026-09-16 16:46:02 +08:00

451 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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