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()。
This commit is contained in:
@ -84,3 +84,28 @@ export function scrapDefective(id: number, data: { scrap_qty?: number; reason: s
|
|||||||
data
|
data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退回流水台账(只读)。
|
||||||
|
*
|
||||||
|
* @param params {
|
||||||
|
* page?, page_size?,
|
||||||
|
* keyword?, 模糊匹配 出库单号 / SKU / 操作人
|
||||||
|
* return_type?, '良品' | '不良品';'全部' 或留空 = 不过滤
|
||||||
|
* start_date?, end_date? 按退回时间过滤(YYYY-MM-DD)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function getReturnList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
keyword?: string
|
||||||
|
return_type?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
}) {
|
||||||
|
return request({
|
||||||
|
url: '/outbound/returns',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -46,18 +46,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 1. 获取当前激活的菜单路径
|
// 1. 获取当前激活的菜单路径
|
||||||
const activeMenu = computed(() => {
|
const activeMenu = computed(() => {
|
||||||
return route.path
|
return route.path
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. 获取需要在菜单中显示的路由(过滤掉 hidden 的路由)
|
// 2. ★ 单个路由是否对当前用户可见
|
||||||
|
//
|
||||||
|
// 只认 meta.permissions(数组,满足任一即可)。历史遗留的
|
||||||
|
// meta.permission(单数)**刻意保持不生效** —— 一旦开始消费它,
|
||||||
|
// 「维修管理」等菜单会对部分角色突然消失,属于超出本次范围的静默行为变更。
|
||||||
|
// 若要统一,需单独排期并逐个核对其余 meta.permission 的影响面。
|
||||||
|
const canSee = (meta: any) => {
|
||||||
|
if (!meta) return true
|
||||||
|
if (meta.hidden) return false
|
||||||
|
|
||||||
|
const need = meta.permissions
|
||||||
|
if (!Array.isArray(need) || need.length === 0) return true
|
||||||
|
return need.some((code: string) => userStore.hasPermission(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取需要在菜单中显示的路由
|
||||||
|
// ★ 必须**递归过滤子路由**:原实现只过滤顶层,子路由的 meta.permissions
|
||||||
|
// 不起作用,未授权用户仍能在菜单里看到入口。父菜单的子项被过滤光后,
|
||||||
|
// 父菜单本身也不再显示。
|
||||||
const menuRoutes = computed(() => {
|
const menuRoutes = computed(() => {
|
||||||
return router.options.routes.filter((r: any) => !r.meta?.hidden)
|
return router.options.routes
|
||||||
|
.filter((r: any) => canSee(r.meta))
|
||||||
|
.map((r: any) => {
|
||||||
|
if (!r.children) return r
|
||||||
|
return { ...r, children: r.children.filter((c: any) => canSee(c.meta)) }
|
||||||
|
})
|
||||||
|
.filter((r: any) => !r.children || r.children.length > 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. 辅助函数:获取 meta 信息
|
// 3. 辅助函数:获取 meta 信息
|
||||||
|
|||||||
@ -109,11 +109,13 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
},
|
},
|
||||||
// ★ 不良品在管台账(逆向物流):退回的坏件不入库存表,
|
// ★ 不良品在管台账(逆向物流):退回的坏件不入库存表,
|
||||||
// 实物在仓但不在库存行中,故单独成页承载其去向与处置。
|
// 实物在仓但不在库存行中,故单独成页承载其去向与处置。
|
||||||
|
// meta.permissions 生效于 Sidebar(菜单隐藏)与路由守卫(直达 URL 拦截),
|
||||||
|
// 后端 list API 同名权限码校验,三处一致。
|
||||||
{
|
{
|
||||||
path: 'defective',
|
path: 'defective',
|
||||||
name: 'DefectiveGoods',
|
name: 'DefectiveGoods',
|
||||||
component: () => import('@/views/stock/defective/index.vue'),
|
component: () => import('@/views/stock/defective/index.vue'),
|
||||||
meta: { title: '不良品在管台账', permission: 'inventory_stocktake' }
|
meta: { title: '不良品在管台账', permissions: ['defective_list'] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -174,6 +176,19 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
title: '出库审批',
|
title: '出库审批',
|
||||||
icon: 'Stamp'
|
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']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -404,15 +419,33 @@ router.beforeEach((to, from, next) => {
|
|||||||
|
|
||||||
// 权限检查逻辑
|
// 权限检查逻辑
|
||||||
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||||
if (to.meta.roles.includes(userRole)) {
|
if (!to.meta.roles.includes(userRole)) {
|
||||||
next()
|
|
||||||
} else {
|
|
||||||
console.warn(`权限不足: 用户角色 ${userRole} 不在允许列表 ${to.meta.roles} 中`)
|
console.warn(`权限不足: 用户角色 ${userRole} 不在允许列表 ${to.meta.roles} 中`)
|
||||||
next('/dashboard')
|
next('/dashboard')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// ★ 权限码检查(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()
|
next()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
193
inventory-web/src/views/outbound/returns/index.vue
Normal file
193
inventory-web/src/views/outbound/returns/index.vue
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 筛选区 -->
|
||||||
|
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||||
|
<el-form-item label="退回类型">
|
||||||
|
<el-select v-model="query.return_type" placeholder="全部" style="width: 140px">
|
||||||
|
<el-option label="全部" value="全部" />
|
||||||
|
<el-option label="良品(加回库存)" value="良品" />
|
||||||
|
<el-option label="不良品(转在管台账)" value="不良品" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="搜索">
|
||||||
|
<el-input
|
||||||
|
v-model="query.keyword"
|
||||||
|
placeholder="出库单号 / SKU / 操作人"
|
||||||
|
style="width: 240px;"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="退回时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 260px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="resetFilter">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="list"
|
||||||
|
v-loading="loading"
|
||||||
|
border
|
||||||
|
style="width: 100%; margin-top: 16px;"
|
||||||
|
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
|
||||||
|
>
|
||||||
|
<el-table-column prop="return_time" label="退回时间" width="170" align="center">
|
||||||
|
<template #default="{ row }">{{ row.return_time || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="outbound_no" label="原出库单号" min-width="190" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.outbound_no">{{ row.outbound_no }}</span>
|
||||||
|
<span v-else class="muted">(原单已删除)</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="material_name" label="物料名称" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.material_name">{{ row.material_name }}</span>
|
||||||
|
<span v-else class="muted">(原库存行已删除)</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="spec_model" label="规格型号" min-width="140" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.spec_model">{{ row.spec_model }}</span>
|
||||||
|
<span v-else class="muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="sku" label="SKU" width="150" show-overflow-tooltip />
|
||||||
|
|
||||||
|
<el-table-column label="退回数量" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ formatQty(row.return_qty) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="退回类型" width="150" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.return_type === '不良品' ? 'danger' : 'success'">
|
||||||
|
{{ row.return_type === '不良品' ? '不良品(转在管)' : '良品(已回库)' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="reason" label="退回原因" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.reason">{{ row.reason }}</span>
|
||||||
|
<span v-else class="muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="operator" label="操作人" width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.operator">{{ row.operator }}</span>
|
||||||
|
<span v-else class="muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; text-align: right;">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="query.page_size"
|
||||||
|
:current-page="query.page"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { getReturnList } from '@/api/inbound/return'
|
||||||
|
import { formatQty } from '@/utils/format'
|
||||||
|
|
||||||
|
// ★ 只读台账:无任何写操作按钮,供库管核对历史退回记录。
|
||||||
|
// 权限由 /outbound/returns 接口的 outbound_return_list 校验,
|
||||||
|
// 菜单可见性与直达 URL 由路由 meta.permissions 控制。
|
||||||
|
|
||||||
|
const list = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
keyword: '',
|
||||||
|
return_type: '全部',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getReturnList({
|
||||||
|
page: query.page,
|
||||||
|
page_size: query.page_size,
|
||||||
|
keyword: query.keyword || undefined,
|
||||||
|
return_type: query.return_type,
|
||||||
|
start_date: dateRange.value?.[0] || undefined,
|
||||||
|
end_date: dateRange.value?.[1] || undefined,
|
||||||
|
})
|
||||||
|
list.value = res.data.list || []
|
||||||
|
total.value = res.data.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[退回记录] 查询失败', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
query.page = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (val: number) => {
|
||||||
|
query.page = val
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilter = () => {
|
||||||
|
query.page = 1
|
||||||
|
query.keyword = ''
|
||||||
|
query.return_type = '全部'
|
||||||
|
dateRange.value = []
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.filter-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user