Commit 7124cf23 by wuqian

feat:权限菜单+左边栏#ID1009779

parent c82edc93
......@@ -21,6 +21,9 @@ export function changePasswordApi(data: { oldPwd: string; newPwd: string }) {
data,
)
}
export function logoutApi() {
return axios.get<never, BaseRespData<never>>('factory/logout')
}
// 用户列表
export function getUserList(
data: userSearchForm,
......
import axios from './axios'
import { BaseRespData } from '@/types/api'
import {
RoleItem,
RoleFormPayload,
RolePermsData,
RoleUser,
MenuTreeNode,
} from '@/types/api/role'
// 角色列表
export function getRoleListApi() {
return axios.get<never, BaseRespData<RoleItem[]>>('/factoryRole/list')
}
// 新增角色
export function addRoleApi(form: RoleFormPayload) {
return axios.post<never, BaseRespData<never>>('/factoryRole/add', {
...form,
type: 'FUNCTION_ROLE',
})
}
// 编辑角色
export function updateRoleApi(form: RoleFormPayload) {
return axios.post<never, BaseRespData<never>>('/factoryRole/update', {
...form,
type: 'FUNCTION_ROLE',
})
}
// 删除角色
export function deleteRoleApi(ids: number | string) {
return axios.get<never, BaseRespData<never>>('/factoryRole/delete', {
params: { ids },
})
}
// 复制角色
export function copyRoleApi(sysRoleId: number | string) {
return axios.post<never, BaseRespData<RoleItem>>(
`/factoryRole/copy-role/${sysRoleId}`,
)
}
// 角色下的用户
export function getRoleUsersApi(id: number | string) {
return axios.get<never, BaseRespData<RoleUser[]>>(
'/factoryRole/get-user-by-role-id',
{
params: { id },
},
)
}
// 保存角色权限
export function saveRoleAuthApi(data: {
roleId: number | string
menuIds: number[]
}) {
return axios.post<never, BaseRespData<never>>(
'/factoryRole/add-role-all',
data,
)
}
// 获取角色已有权限
export function getRolePermsApi(roleId: number | string) {
return axios.get<never, BaseRespData<RolePermsData>>(
'/factoryRole/get-role-perms',
{
params: { roleId },
},
)
}
// 系统菜单树
export function getSysMenusTreeApi() {
return axios.get<never, BaseRespData<MenuTreeNode[]>>(
'factoryMenu/get-factory-menus-tree',
)
}
// 按钮 + 左边栏权限:每个页面把自己的 route 传进来,
// 后端返回当前页可见的 buttons列表
export function getSysPermissionApi(reqMenu: string) {
return axios.get<never, BaseRespData<{ buttons: string[] }>>(
'factoryMenu/request-permission',
{
params: { reqMenu },
},
)
}
......@@ -167,42 +167,56 @@ import {
onUnmounted,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Menu, { type MenuItem } from '@/router/menu'
import { transformMenuTree, type MenuItem } from '@/router/menu'
import userUserStore from '@/store/user'
import useMenuTreeStore from '@/store/menuTree'
import type { FormRules } from 'element-plus'
import { useValue } from '@/utils/hooks/useValue'
import { changePasswordApi } from '@/api/auth'
import { changePasswordApi, logoutApi } from '@/api/auth'
import { ElMessage } from 'element-plus'
import NavMenuItem from './NavMenuItem.vue'
const removeMenuItemByIndex = (items: MenuItem[], indexToRemove: string) => {
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].index === indexToRemove) {
items.splice(i, 1)
} else if (items[i].children?.length) {
removeMenuItemByIndex(items[i].children!, indexToRemove)
}
}
}
interface PasswordForm {
oldPwd: string
newPwd: string
confirmPwd?: string
}
const route = useRoute()
const menuList = reactive(Menu)
const menuTreeStore = useMenuTreeStore()
const userStore = userUserStore()
const userInfo = userStore.user
const DASHBOARD_PATH = '/dashboard'
const DASHBOARD_TITLE = '概览'
const displayMenuList = computed(() =>
menuList.filter((item) => item.index !== DASHBOARD_PATH),
)
// 工厂类型 !== 2 时,不显示 /system/customers 菜单入口(路由仍可访问)
const isShowCustomerManagement = computed(() => userInfo?.factory?.type === 2)
const removeMenuItemByIndex = (
items: MenuItem[],
indexToRemove: string,
): MenuItem[] => {
return items
.filter((item) => item.index !== indexToRemove)
.map((item) =>
item.children?.length
? {
...item,
children: removeMenuItemByIndex(item.children, indexToRemove),
}
: item,
)
}
const userStore = userUserStore()
const userInfo = userStore.user
const displayMenuList = computed<MenuItem[]>(() => {
let list = transformMenuTree(menuTreeStore.menusTreeNoBtn)
if (!isShowCustomerManagement.value) {
list = removeMenuItemByIndex(list, '/system/customers')
}
return list.filter((item) => item.index !== DASHBOARD_PATH)
})
const dialogVisible = ref(false)
// 密码form
const [passwordForm, resetPasswordForm] = useValue<PasswordForm>(
......@@ -240,9 +254,12 @@ const rules = reactive<FormRules<PasswordForm>>({
],
})
const logout = () => {
const logout = async () => {
await logoutApi()
userStore.logout()
window.location.reload()
setTimeout(() => {
window.location.reload()
}, 600)
}
const resetPassword = () => {
dialogVisible.value = true
......@@ -412,21 +429,6 @@ watch(activeTab, (newTab) => {
})
watch(scrollableTabs, checkScrollArrows)
onMounted(() => {
let showCustomerManagement = false
const userJson = localStorage.getItem('user')
if (userJson) {
try {
const user = JSON.parse(userJson)
if (user && user.factory && user.factory.type === 2) {
showCustomerManagement = true
}
} catch (e) {
// ignore
}
}
if (!showCustomerManagement) {
removeMenuItemByIndex(menuList, '/system/customers')
}
// 初始检查
checkScrollArrows()
......
<template>
<div class="page_main h-100" :class="{ mobile: isMobile }">
<!-- 头部插槽 -->
<div v-if="$slots.header" class="page_header">
<slot name="header" />
</div>
<div class="layout_wrap" :style="wrapStyle">
<!-- 左侧插槽 -->
<div
v-if="$slots.page_left"
class="page_left"
:class="[isLeft ? 'page_left_true' : 'page_left_false']"
:style="leftStyle"
>
<slot name="page_left" />
</div>
<!-- 分隔栏 / 折叠按钮 -->
<div v-if="props.isleftshow" class="separation">
<span
class="left_toggle"
:title="isLeft ? '点击收起' : '点击展开'"
@click="leftChange"
>
<i :class="isLeft ? 'el-icon-caret-left' : 'el-icon-caret-right'" />
</span>
</div>
<!-- 右侧插槽 -->
<div v-if="$slots.page_right" class="page_right" :style="rightStyle">
<slot name="page_right" />
</div>
<!-- 默认插槽 -->
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, useAttrs } from 'vue'
import type { CSSProperties } from 'vue'
import { isMobile } from '@/utils/index.ts'
const props = defineProps({
isleftshow: {
type: Boolean,
default: true,
},
})
// 获取透传的 attrs,并定义类型
interface CustomAttrs {
leftStyle?: CSSProperties
rightStyle?: CSSProperties
style?: CSSProperties
}
const attrs = useAttrs() as CustomAttrs
const isLeft = ref(true)
const leftChange = () => {
isLeft.value = !isLeft.value
}
const leftStyle = computed<CSSProperties>(() => {
const { leftStyle = {} } = attrs
const { width = '200px', ...rest } = leftStyle
return {
...rest,
width: isLeft.value ? width : '1px',
}
})
const rightStyle = computed<CSSProperties>(() => attrs.rightStyle || {})
const wrapStyle = computed<CSSProperties>(() => attrs.style || {})
</script>
<style scoped>
.page_main {
box-sizing: border-box;
display: flex;
flex-direction: column;
margin: 0;
overflow: hidden;
position: relative;
}
.page_main .layout_wrap {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* .page_main:not(.mobile) {
margin-right: 54px;
} */
.page_main.row .layout_wrap {
flex-direction: row;
background: #fff;
}
.page_main.wrap .layout_wrap {
background: #fff;
box-sizing: border-box;
border-radius: 10px;
padding-top: 10px;
}
.page_main.wrap .layout_wrap :deep(form:nth-of-type(1)) {
margin-left: 10px;
}
.page_main.mobile {
height: calc(100% - 9.333vw);
}
.page_header {
background: #fff;
padding-top: 10px;
margin-bottom: 10px;
}
.page_left {
position: relative;
display: flex;
flex-direction: column;
width: 200px;
height: 100%;
background: var(--background-color);
box-sizing: border-box;
border-radius: 5px 5px 0 0;
overflow: auto;
border-right: 1px solid #ddd;
}
.separation {
display: none;
position: relative;
width: 1px;
}
.row .separation {
display: block;
}
.separation .left_toggle {
position: absolute;
right: -16px;
width: 17px;
height: 24px;
background: #dedede;
z-index: 1000;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.separation .left_toggle::before {
position: absolute;
width: 0px;
height: 0px;
content: '';
top: 10px;
left: -12px;
pointer-events: none;
transform: rotate(45deg);
border-width: 12px;
border-style: solid;
border-color: rgba(0, 0, 0, 0) #dedede rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);
border-radius: 5px;
}
.separation .left_toggle::after {
position: absolute;
width: 0px;
height: 0px;
top: -10px;
left: -12px;
pointer-events: none;
content: '';
transform: rotate(-45deg);
border-width: 12px;
border-style: solid;
border-color: rgba(0, 0, 0, 0) #dedede rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);
border-radius: 5px;
}
.separation .left_toggle i {
display: block;
margin-top: 3px;
pointer-events: none;
}
.separation:hover .left_toggle {
display: block;
}
.page_right {
flex: 1;
display: flex;
flex-direction: column;
margin-left: 10px;
box-sizing: border-box;
overflow: hidden;
position: relative;
}
.page_main:not(.drag) .page_right {
background: var(--background-color);
border-radius: 5px 5px 0 0;
padding-top: 10px;
box-sizing: border-box;
}
/* 深度选择器 */
.page_main .page_right :deep(form:nth-of-type(1)),
.page_main .page_left :deep(form),
.page_main.drag :deep(form) {
padding-left: 10px;
}
.page_main.drag .page_right {
background: transparent;
}
</style>
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import router, { registerDynamicRoutes } from './router'
import store from './store'
import './styles/index.scss'
import roleShow from './utils/roleShow'
import VxeUITable from 'vxe-table'
import 'vxe-table/lib/style.css'
import { isPermissionBtn } from '@/utils/index'
import { getToken } from '@/api/axios'
import { buildDynamicRoutes } from '@/router/dynamicRoutes'
import { MenuTreeNode } from '@/types/api/role'
// 确保在渲染用户提供的HTML内容时,不会执行任何潜在的恶意脚本,从而提高应用的安全性
import vueDomPurifyHTMLPlugin from 'vue-dompurify-html'
/**
* 应用挂载前还原动态路由:用户已登录时把 localStorage 里的 sysMenus
* 重新挂到 / (Home) 下。必须先于 app.mount(),否则首次路由解析时
* 业务路由还没注册,会出现刷新即 404。
*/
function restoreDynamicRoutes() {
if (!getToken()) return
try {
const raw = localStorage.getItem('sysMenus')
if (!raw) return
const parsed = JSON.parse(raw) as MenuTreeNode[]
if (Array.isArray(parsed) && parsed.length) {
registerDynamicRoutes(buildDynamicRoutes(parsed))
}
} catch {
// ignore
}
}
restoreDynamicRoutes()
const app = createApp(App)
app
.use(vueDomPurifyHTMLPlugin)
.use(router)
.use(store)
.use(roleShow)
.use(VxeUITable)
.mount('#app')
app.config.globalProperties.$isPermissionBtn = isPermissionBtn
import type { RouteRecordRaw } from 'vue-router'
import type { MenuTreeNode } from '@/types/api/role'
/**
* 动态路由注册:根据登录接口返回的 sysMenus 树生成路由记录,
* 并挂到 / (Home) 之下。
*
* - 叶子节点(path 以 / 开头)查表得到 component,register 到 / 下
* - /system/downloadClient 是特例:保留原 beforeEnter 下载逻辑
* - 找不到 component 的路径直接跳过(防御后端乱返)
*/
type RouteComponent = RouteRecordRaw['component']
type LazyComponent = () => Promise<{ default: RouteComponent } | RouteComponent>
// path -> lazy import。Dashboard 保留为同步导入以便静态路由共用
import Dashboard from '@/views/Dashboard.vue'
export const dynamicComponentMap: Record<string, LazyComponent> = {
'/order/list': () => import('@/views/order/index.vue'),
'/pod-order/list': () => import('@/views/order/pod/index.vue'),
'/pod-cn-order/list': () => import('@/views/order/podCN/index.vue'),
'/pod-us-order/list': () => import('@/views/order/podUs/index.vue'),
'/pod-us-order/orderTracking': () =>
import('@/views/order/orderTracking/index.vue'),
'/pod-us-order/podUsSchedulingRules': () =>
import('@/views/order/podUsSchedulingRules/index.vue'),
'/order/factory-order-new': () =>
import('@/views/order/factoryOrderNew/index.vue'),
'/order/cancel-order-process': () =>
import('@/views/order/cancelOrderProcess/index.vue'),
'/order/allocation-sorting-rule': () =>
import('@/views/order/allocationSortingRule/index.vue'),
'/pod-cn-order/orderTracking': () =>
import('@/views/order/orderTracking/indexcn.vue'),
'/pod-delivery-note/list': () =>
import('@/views/order/pod/deliveryOrderList.vue'),
'/production/complete': () => import('@/views/production/complete.vue'),
'/system/user': () => import('@/views/system/UserPage.vue'),
'/system/role-manage': () => import('@/views/system/RoleManagementPage.vue'),
'/system/external-authorisation': () =>
import('@/views/system/externalAuthorisationPage.vue'),
'/system/customers': () => import('@/views/system/CustomersPage.vue'),
'/supply/out-of-stock-statistics': () =>
import('@/views/supply/OutOfStockStatistics.vue'),
'/supply/stocking-order': () =>
import('@/views/supply/stockingOrder/index.vue'),
'/system/delivery-note': () => import('@/views/DeliveryNotePage.vue'),
'/account/statement-note': () => import('@/views/AccountStatementNote.vue'),
'/account/pod-bill-order': () => import('@/views/podBillOrder/index.vue'),
'/account/pod-us-bill-order': () =>
import('@/views/podUsBillOrder/index.vue'),
'/typesetting-management/list': () =>
import('@/views/typesetting/TypesettingManagement.vue'),
'/logistics/logisticsMethod': () =>
import('@/views/logistics/logisticsMethod.vue'),
'/logistics/logisticsCompany': () =>
import('@/views/logistics/logisticsCompany.vue'),
'/logistics/shippingAddress': () =>
import('@/views/logistics/shippingAddress.vue'),
'/logistics/logisticsQuotation': () =>
import('@/views/logistics/logisticsQuotation.vue'),
'/logistics/declarationRule': () =>
import('@/views/logistics/declarationRule.vue'),
'/logistics/logisticsPartition': () =>
import('@/views/logistics/logisticsPartition.vue'),
'/logistics/logisticsCalculate': () =>
import('@/views/logistics/logisticsCalculate.vue'),
'/logistics/sortingConfiguration': () =>
import('@/views/logistics/sortingConfiguration.vue'),
'/logistics/logisticsTracking': () =>
import('@/views/logistics/logisticsTracking.vue'),
'/warehouse/manage': () => import('@/views/warehouse/manage.vue'),
'/warehouse/receipt-doc': () => import('@/views/warehouse/receiptDoc.vue'),
'/warehouse/issue-doc': () => import('@/views/warehouse/issueDoc.vue'),
'/warehouse/stocking-plan': () =>
import('@/views/warehouse/stockingPlan.vue'),
'/warehouse/stocking-application': () =>
import('@/views/warehouse/stockingApplicationOrder/index.vue'),
'/warehouse/warning': () => import('@/views/warehouse/warning.vue'),
'/warehouse/position': () => import('@/views/warehouse/position.vue'),
'/setting/settingIndex': () => import('@/views/setting/settingIndex.vue'),
'/MyDownloads': () => import('@/views/MyDownloads.vue'),
'/supply/supplierManagement': () =>
import('@/views/supply/supplierManagement/index.vue'),
}
/** dashboard 是登录后的默认页,保留为静态路由 */
export { Dashboard }
/**
* 把后端返回的叶子节点规范化为 router 用的完整路径。
* 优先级:route > path > 顶层名+叶子名/name
* - route 已带 / → 原样返回
* - path 含 _ → 把 _ 替换成 /,并补 / 前缀
* - 都没有 → 用顶层 name + 叶子名兜底
*/
export function buildLeafPath(
leaf: MenuTreeNode,
topName: string = '',
): string {
const r = leaf.route
if (r?.startsWith('/')) return r
if (r) return '/' + r.replace(/_/g, '/')
const p = leaf.path
if (p?.startsWith('/')) return p
if (p?.includes('_')) {
return '/' + p.replace(/_/g, '/')
}
const leafPart = p || leaf.name
if (!leafPart) return ''
if (topName) {
return `/${topName}/${leafPart}`
}
return `/${leafPart}`
}
/** 把所有叶子 path 从 sysMenus 树里展平为路由可用的完整路径 */
export function flattenLeafPaths(nodes: MenuTreeNode[] = []): string[] {
return flattenLeafNodes(nodes).map((n) => n.path)
}
/**
* 展平 sysMenus 的所有叶子节点,输出 { path, title },
* 供 buildDynamicRoutes 注册路由时写入 meta.title。
*/
export function flattenLeafNodes(
nodes: MenuTreeNode[] = [],
): Array<{ path: string; title: string }> {
const out: Array<{ path: string; title: string }> = []
for (const top of nodes) {
const topName = top.name
const walk = (arr: MenuTreeNode[]) => {
for (const n of arr) {
if (n.children?.length) {
walk(n.children)
} else {
const p = buildLeafPath(n, topName)
if (p) out.push({ path: p, title: n.name || n.title })
}
}
}
if (top.children?.length) {
walk(top.children)
} else {
const p = buildLeafPath(top, topName)
if (p) out.push({ path: p, title: top.name || top.title })
}
}
return out
}
/**
* 把 sysMenus 转换成可注册到 router 的 RouteRecordRaw 列表。
*/
export function buildDynamicRoutes(
menus: MenuTreeNode[] = [],
): RouteRecordRaw[] {
const leaves = flattenLeafNodes(menus)
const records: RouteRecordRaw[] = []
for (const { path: p, title } of leaves) {
if (p === '/system/downloadClient') {
// 特殊路由:触发 exe 下载,无组件
records.push({
path: p,
component: { render: () => null } as unknown as RouteComponent,
beforeEnter() {
const a = document.createElement('a')
a.href = '/exeFiles/JomallProductionAssistantSetup.exe'
a.download = '生产客户端.exe'
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
},
meta: { title: '下载生产客户端' },
} as RouteRecordRaw)
continue
}
const loader = dynamicComponentMap[p]
if (!loader) continue
records.push({
path: p,
// 使用异步加载的 component
component: loader as unknown as RouteComponent,
meta: { title },
} as RouteRecordRaw)
}
return records
}
import type { MenuTreeNode } from '@/types/api/role'
export interface MenuItem {
index: string
id: number
......@@ -5,6 +7,29 @@ export interface MenuItem {
children?: MenuItem[]
}
/**
* 把后端返回的 MenuTreeNode 树转换为导航菜单使用的 MenuItem 树。
*/
export function transformMenuTree(
nodes: MenuTreeNode[] = [],
topName: string = '/',
): MenuItem[] {
return nodes.map((n) => {
// 只有"无 children 且 route 是真实路径"才是可点的叶子
const hasRealRoute = Boolean(n.route) && n.route !== '/'
const isLeaf = !n.children?.length && hasRealRoute
return {
id: n.id,
label: n.name || n.title,
// 叶子:直接用 route;非叶子:
index: isLeaf ? n.route! : topName,
children: n.children?.length
? transformMenuTree(n.children, topName)
: undefined,
}
})
}
const menu: MenuItem[] = [
{
index: '/dashboard',
......@@ -251,6 +276,7 @@ const menu: MenuItem[] = [
children: [
{ index: '/setting/settingIndex', id: 821, label: '工厂设置' },
{ index: '/system/downloadClient', id: 822, label: '下载生产客户端' },
{ index: '/system/role-manage', id: 823, label: '功能角色管理' },
],
},
],
......
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { MenuTreeNode } from '@/types/api/role'
const STORAGE_KEY = 'sysMenus'
function readFromStorage(): MenuTreeNode[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as MenuTreeNode[]) : []
} catch {
return []
}
}
function writeToStorage(data: MenuTreeNode[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch {
// ignore quota errors
}
}
const useMenuTreeStore = defineStore('menuTree', () => {
const menusTree = ref<MenuTreeNode[]>(readFromStorage()) // 功能按钮里的菜单(带左边栏和按钮)
const menusTreeNoBtn = ref<MenuTreeNode[]>(readFromStorage()) // 专门渲染三级菜单(带左边栏和按钮)
const reqMenu = ref<{ buttons: string[] }>({
buttons: [],
})
const setMenuTree = (data: MenuTreeNode[]) => {
menusTree.value = data
writeToStorage(data)
}
const clearMenuTree = () => {
menusTree.value = []
menusTreeNoBtn.value = []
reqMenu.value = { buttons: [] }
localStorage.removeItem(STORAGE_KEY)
}
const setMenuTreeNoBtn = (data: MenuTreeNode[]) => {
menusTreeNoBtn.value = data
writeToStorage(data)
}
const setReqMenu = (data: { buttons: string[] }) => {
reqMenu.value = data
}
return {
menusTree,
menusTreeNoBtn,
reqMenu,
setMenuTree,
clearMenuTree,
setReqMenu,
setMenuTreeNoBtn,
}
})
export default useMenuTreeStore
import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus'
import { LoginReq, SysUser } from '@/types/api/auth'
import { loginApi } from '@/api/auth'
import { setToken } from '@/api/axios'
import useMenuTreeStore from '@/store/menuTree'
import { buildDynamicRoutes } from '@/router/dynamicRoutes'
import { registerDynamicRoutes } from '@/router'
import router from '@/router'
export interface AppStoreState {
......@@ -30,7 +34,16 @@ const useUserStore = defineStore('user', {
async login(data: LoginReq) {
try {
const resp = await loginApi(data)
console.log(33, resp)
const { sysMenus } = resp.data
if (!sysMenus || sysMenus.length === 0) {
ElMessage.error('未分配菜单权限,请联系管理员')
this.logout()
return
}
// 菜单树
useMenuTreeStore().setMenuTreeNoBtn(sysMenus)
// 根据 sysMenus 动态注册路由到 / (Home) 下
registerDynamicRoutes(buildDynamicRoutes(sysMenus))
this.setUser(resp.data.sysUser)
setToken(resp.data.token)
......@@ -47,6 +60,11 @@ const useUserStore = defineStore('user', {
async logout() {
this.setUser(undefined)
setToken('')
// 清空动态菜单和路由
useMenuTreeStore().clearMenuTree()
// 清掉登录时写入的其他鉴权相关缓存
localStorage.removeItem('baseExternalAccountTypes')
// localStorage.removeItem('factory_code')
},
},
})
......
import type { MenuTreeNode } from './role'
export interface LoginReq {
factoryCode: string
account: string
......@@ -13,6 +15,7 @@ export interface LoginResp {
export interface Root {
sysUser: SysUser
token: string
sysMenus: MenuTreeNode[]
}
export interface SysUser {
......@@ -32,6 +35,7 @@ export interface Factory {
bianma: string
title: string
typeId: number
type?: number
name: string
mobile: string
goodsNumber: number
......
export interface RoleItem {
id: number;
roleGroup: string;
name: string;
type: string;
createTime: string;
}
export interface RoleFormPayload {
id?: number | string
name: string
roleGroup?: string
[key: string]: unknown
}
export interface RoleGroup {
label: string
list: RoleItem[]
}
export interface RoleUser {
id: number
account: string
}
export interface RolePermsData {
dataRoleIds?: number[]
menuIds?: number[]
[key: string]: unknown
}
export interface MenuTreeNode {
id: number;
name: string;
title: string;
path: string;
url: string;
route: string;
pid: number;
type: number;
sortNo: number;
enable: boolean;
indeter: boolean;
includeNamespace: string;
excludeNamespace: string;
children: MenuTreeNode[];
}
\ No newline at end of file
......@@ -7,6 +7,7 @@ export interface userData {
factoryCode?: string
status?: number
createTime?: string
firstSupperMark?: boolean
}
export interface userSearchForm {
......@@ -19,6 +20,8 @@ export interface UserEditForm {
supperMark: string | number
account: string
status: string | number
roleIds: number[] | string
firstSupperMark?: boolean
}
export interface customSearchForm {
sku?: string
......
......@@ -73,7 +73,7 @@ export interface ExportInWarehouseInfo extends warehouseSearchForm {
}
export interface InterWarehouseTree {
id?: number
code?: string | null
code: string | null
name?: string
countQuantity?: string | null
children: InterWarehouseTree[] | null
......
......@@ -65,3 +65,7 @@ export function downloadByUrl(url: string, filename?: string) {
a.click()
document.body.removeChild(a)
}
export const isMobile: boolean =
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i.test(
navigator.userAgent,
)
\ No newline at end of file
import { App } from 'vue'
import useMenuTreeStore from '@/store/menuTree'
/**
* 判断当前页面是否有指定按钮 / 权限 code。
* 既能在 <template> 里直接用(通过 app.config.globalProperties),
* 也能在 <script setup> 里 import 使用。
*/
export function roleShow(name?: string | string[] | null): boolean {
// 必须在 install() 里调用 useMenuTreeStore(),
// 不能在模块顶层调用 —— 否则 main.ts 在 app.use(store) 安装 pinia 之前
// import 这文件就会触发 "getActivePinia()" was called but there was no active Pinia
if (!name) return false
const menuTreeStore = useMenuTreeStore()
const roles = menuTreeStore.reqMenu.buttons
if (!roles) return false
if (Array.isArray(name)) {
return name.some((key) => roles.includes(key))
}
return roles.includes(name)
}
export default {
// 已经在 main.ts 中注册了 roleShow
// 其中左边栏里的 statusCode 是后端返回的,在sass-manage里添加时要和后端返回的statusCode一致
install(app: App) {
app.config.globalProperties.roleShow = roleShow
},
}
<template>
<div class="home">
<!-- 顶部导航栏 -->
<NavMenu />
<!-- 顶部导航栏:key 随用户变化,账号切换时强制重挂载,菜单名称才会更新 -->
<NavMenu :key="navMenuKey" />
<div class="container">
<RouterView />
<!-- key 随登录用户变化,强制重渲染避免切换账号后页面残留旧数据 -->
<RouterView :key="routerViewKey" />
</div>
<SideBar />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NavMenu from '@/components/NavMenu.vue'
import SideBar from '@/components/SideBar.vue'
import useUserStore from '@/store/user'
const userStore = useUserStore()
const userKey = computed(() => userStore.user?.id ?? 'guest')
const routerViewKey = userKey
const navMenuKey = userKey
</script>
<style lang="scss" scoped>
.home {
......
......@@ -7,6 +7,7 @@ import {
} from '@/api/factoryOrderNew'
import type { StatusTreeNode } from '@/types/api/order/factoryOrderNew'
import type { SearchForm } from '@/types/api/factoryOrderNew'
import { roleShow } from '@/utils/roleShow'
interface UseOrderStatusTreeOptions {
getQueryPayload: () => Record<string, unknown>
......@@ -15,11 +16,7 @@ interface UseOrderStatusTreeOptions {
}
export function useOrderStatusTree(options: UseOrderStatusTreeOptions) {
const {
getQueryPayload,
currentPage,
pageSize,
} = options
const { getQueryPayload, currentPage, pageSize } = options
const cardLayoutStatuses = [
'PENDING_SCHEDULE',
......@@ -44,7 +41,12 @@ export function useOrderStatusTree(options: UseOrderStatusTreeOptions) {
acceptedOutOfStockCount: 0,
})
const suspendedTabs = ref([
{ label: '客户拦截-取消订单', key: 'customerInterceptCount', value: 1, count: 0 },
{
label: '客户拦截-取消订单',
key: 'customerInterceptCount',
value: 1,
count: 0,
},
{ label: '地址异常', key: 'addressExceptionCount', value: 2, count: 0 },
{ label: '素材异常', key: 'materialExceptionCount', value: 3, count: 0 },
{ label: '其他', key: 'otherReasonCount', value: 4, count: 0 },
......@@ -130,12 +132,26 @@ export function useOrderStatusTree(options: UseOrderStatusTreeOptions) {
console.error(e)
}
}
const loadStatusTreeCounts = async () => {
try {
const res = await getPodOrderStateGroupListApi()
const GROUP_MARK = new Set(['PICKING']) // "配货中—" 这样的“分组容器”要自己加
const filterTree = (nodes: StatusTreeNode[]): StatusTreeNode[] => {
const result: StatusTreeNode[] = []
for (const node of nodes) {
if (GROUP_MARK.has(node.status) || roleShow(node.status)) {
const newNode = { ...node }
if (newNode.children) {
newNode.children = filterTree(newNode.children)
}
result.push(newNode)
}
}
return result
}
const result = filterTree(res.data) // 递归过滤掉没有权限的节点
statusTree.value = [
{ status: 'ALL', statusName: '全部', quantity: 0, children: res.data },
{ status: 'ALL', statusName: '全部', quantity: 0, children: result },
]
nextTick(() => {
treeRef.value?.setCurrentKey(status.value, true)
......@@ -145,7 +161,6 @@ export function useOrderStatusTree(options: UseOrderStatusTreeOptions) {
}
}
const handlePendingAcceptTabClick = (
tab: 'PENDING_RECEIVE' | 'ACCEPT_FAIL_OUT_OF_STOCK',
refreshTableList: () => void,
......@@ -180,6 +195,6 @@ export function useOrderStatusTree(options: UseOrderStatusTreeOptions) {
getCancelledCounts,
loadStatusTreeCounts,
handlePendingAcceptTabClick,
toggleExpand
toggleExpand,
}
}
......@@ -39,6 +39,7 @@
<div class="user-list flex-1 overflow-hidden">
<ElTable
:data="tableData"
border
default-expand-all
style="width: 100%; height: 100%"
@selection-change="handleSelectionChange"
......@@ -77,6 +78,13 @@
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="account" header-align="center" label="功能角色">
<template #default="scope">
<div>
<span>{{ getRoleName(scope.row.roleIds) }} </span>
</div>
</template>
</ElTableColumn>
<ElTableColumn
prop="factoryCode"
header-align="center"
......@@ -114,6 +122,9 @@
size="24"
title="编辑"
color="#EF6C00"
:disabled="
scope.row.firstSupperMark && scope.row.supperMark === 1
"
style="cursor: pointer; vertical-align: middle"
@click="editUser(scope.row)"
>
......@@ -175,6 +186,24 @@
:show-password="true"
/>
</ElFormItem>
<ElFormItem label="功能角色" prop="roleIds">
<ElSelect
v-model="editForm.roleIds"
placeholder="请选择功能角色"
multiple
:disabled="editForm.firstSupperMark && editForm.supperMark === '1'"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in roleList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="状态" prop="status">
<ElCheckbox
v-model="editForm.status"
......@@ -183,14 +212,14 @@
false-value="0"
></ElCheckbox>
</ElFormItem>
<ElFormItem label="角色" prop="supperMark">
<!-- <ElFormItem label="角色" prop="supperMark">
<ElCheckbox
v-model="editForm.supperMark"
label="超级管理员"
true-value="1"
false-value="0"
></ElCheckbox>
</ElFormItem>
</ElFormItem> -->
</ElForm>
</div>
<template #footer>
......@@ -226,6 +255,8 @@ const [editForm, resetEditForm] = useValue<UserEditForm>({
password: '',
supperMark: '0',
status: '1',
roleIds: [],
firstSupperMark: false,
})
const {
currentPage,
......@@ -255,6 +286,12 @@ const rules = reactive<FormRules<UserEditForm>>({
message: '请输入密码',
},
],
roleIds: [
{
required: true,
message: '请选择功能角色',
},
],
})
const editId = ref<number | undefined>(undefined)
const addUser = () => {
......@@ -270,6 +307,16 @@ const deleteUser = async () => {
offset: window.innerHeight / 2,
})
}
const isDel = selection.value.some(
(item: userData) => item.firstSupperMark && item.supperMark === 1,
)
if (isDel) {
return ElMessage({
message: '超级管理员不能删除',
type: 'error',
offset: window.innerHeight / 2,
})
}
try {
await showConfirm('是否删除用户', {
confirmButtonText: '确认',
......@@ -298,30 +345,65 @@ const editUser = async (item: userData) => {
try {
const res = await getDetailsByIdApi(item.id)
res.data.supperMark = res.data.supperMark + ''
res.data.firstSupperMark = item.firstSupperMark
res.data.status = res.data.status + ''
editForm.value = res.data
editForm.value.roleIds =
typeof res.data.roleIds === 'string'
? res.data.roleIds.split(',').map(Number)
: res.data.roleIds ?? []
dialogVisible.value = true
} catch (e) {
//showError(e)
}
}
onMounted(() => getRoleList())
import { getRoleListApi } from '@/api/role'
const roleList = ref<{ id: number; name: string }[]>([])
const getRoleList = async () => {
try {
const res = await getRoleListApi()
roleList.value = res.data || []
} catch (err) {
console.log(err)
return
}
}
const getRoleName = (roleIds: number[] | string) => {
if (!roleIds) return ''
const ids = Array.isArray(roleIds)
? roleIds
: String(roleIds).split(',').filter(Boolean).map(Number)
return roleList.value
.filter((item: { id: number }) => ids.includes(item.id))
.map((item: { name: string }) => item.name)
.join(',')
}
const save = async () => {
try {
await editFormRef.value.validate()
} catch {
return
}
const roleIdsStr = Array.isArray(editForm.value.roleIds)
? editForm.value.roleIds.join(',')
: editForm.value.roleIds
const hasSuper = Array.isArray(editForm.value.roleIds)
? editForm.value.roleIds.includes(1)
: editForm.value.roleIds.split(',').map(Number).includes(1)
try {
if (!editId.value) {
await addUserApi({
...editForm.value,
supperMark: Number(editForm.value.supperMark),
roleIds: roleIdsStr,
supperMark: hasSuper ? 1 : 0,
status: Number(editForm.value.status),
})
} else {
await updateUserApi({
...editForm.value,
supperMark: Number(editForm.value.supperMark),
roleIds: roleIdsStr,
supperMark: hasSuper ? 1 : 0,
status: Number(editForm.value.status),
})
}
......
<template>
<div class="menu_wrap">
<ElScrollbar
style="
height: calc(100% - 10px);
min-width: 170px;
overflow-y: auto;
overflow-x: hidden;
margin-right: 10px;
border: 1px solid #ddd;
border-radius: 5px;
"
>
<div
v-for="(item, index) in menuList"
:key="index"
class="menu_title"
:class="{ active: index === activeIndex }"
@click="setActive(index)"
>
{{ formatFirstName(item.name) }}
</div>
</ElScrollbar>
<div style="flex: 1; display: flex; flex-direction: column">
<slot name="header"></slot>
<ElScrollbar
ref="scrollBarRef"
style="flex: 1; background: #fff; border-radius: 10px"
>
<div v-for="(item, index) in menuList" :key="index" class="scrollbar">
<slot v-bind="item" :index="index"></slot>
</div>
</ElScrollbar>
</div>
</div>
</template>
<script setup lang="ts">
import { capitalize, throttle } from 'lodash-es'
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import type { MenuTreeNode } from '@/types/api/role'
const props = withDefaults(
defineProps<{
menuList?: MenuTreeNode[]
isActive?: boolean
}>(),
{
menuList: () => [],
isActive: false,
},
)
const emit = defineEmits<{
(e: 'setActive', item: MenuTreeNode, index: number): void
}>()
defineSlots<{
header(): unknown
default(props: MenuTreeNode & { index: number }): unknown
}>()
const scrollBarRef = ref<{
wrapRef: Ref<HTMLElement | undefined>
setScrollTop?: (value: number) => void
} | null>(null)
const activeIndex = ref(0)
const itemHeight = ref<number[]>([])
const notScroll = ref(false)
function getWrap(): HTMLElement | undefined {
return scrollBarRef.value?.wrapRef;
}
function formatFirstName(value: unknown) {
const str = String(value ?? '')
if (/^[a-zA-Z]+$/.test(str)) {
return capitalize(str)
}
return str
}
function setHeight() {
nextTick(() => {
if (!props.isActive) return
const wrap = getWrap()
if (!wrap) return
const list = wrap.querySelectorAll('.scrollbar')
itemHeight.value = []
let h = 0
itemHeight.value.push(h)
list.forEach((el) => {
h += (el as HTMLElement).clientHeight
itemHeight.value.push(h)
})
})
}
function setActive(index: number) {
notScroll.value = true
activeIndex.value = index
const wrap = getWrap()
if (wrap) {
wrap.scrollTop = itemHeight.value[index] ?? 0
}
window.setTimeout(() => {
notScroll.value = false
}, 200)
emit('setActive', props.menuList[index], index)
}
const scrollMenu = throttle((event: Event) => {
if (notScroll.value) return
const target = event.target as HTMLElement
const newy = target.scrollTop + 100
const heights = itemHeight.value
for (let i = 0; i < heights.length - 1; i++) {
const h1 = heights[i]
const h2 = heights[i + 1]
if (newy >= h1 && newy < h2) {
if (activeIndex.value !== i) {
activeIndex.value = i
}
return
}
}
}, 200)
function onScroll(event: Event) {
scrollMenu(event)
}
onMounted(() => {
const wrap = getWrap()
if (wrap) {
wrap.addEventListener('scroll', onScroll)
}
setHeight()
})
onBeforeUnmount(() => {
const wrap = getWrap()
if (wrap) {
wrap.removeEventListener('scroll', onScroll)
}
})
watch(
() => props.menuList,
(value, oldValue) => {
if (value !== oldValue) setHeight()
},
)
watch(
() => props.isActive,
(v) => {
if (v && itemHeight.value.length === 0) {
setHeight()
}
},
)
</script>
<style scoped>
.menu_wrap {
height: 100%;
display: flex;
}
.menu_title {
padding-left: 10px;
height: 36px;
line-height: 36px;
font-weight: bold;
font-size: 14px;
cursor: pointer;
}
.menu_title:hover,
.menu_title.active {
color: #333333;
}
.menu_title:hover {
background: #f5f7fa;
}
.menu_title.active {
background: #409eff;
}
.scrollbar {
margin-bottom: 10px;
border-radius: 10px;
border: 1px solid #ddd;
}
</style>
......@@ -1057,7 +1057,7 @@ function getStartTime() {
return `${year}-${month}-${day} 00:00:00`
}
const selectSku = ref('')
const treeData = ref<InterWarehouseTree[]>()
const treeData = ref<InterWarehouseTree[]>([])
const [searchForm, resetSearchForm] = useValue<warehouseSearchForm>({})
const tradingTime = ref<string[]>([])
......@@ -1114,11 +1114,14 @@ const setCostPrice = (item: InterProductList) => {
item.totalPrice = Number(amount)
}
}
import { roleShow } from '@/utils/roleShow'
const getTreeNum = async () => {
try {
const res = await getOutRecordStatusTree()
res.data = [{ code: 'all', name: '全部', children: res.data }]
treeData.value = res.data
const result = res.data.filter((item: InterWarehouseTree) =>
roleShow(item.code),
)
treeData.value = [{ code: 'all', name: '全部', children: result }]
await nextTick(() => {
treeRef.value!.setCurrentKey(nodeCode.value, true)
})
......
......@@ -653,7 +653,8 @@ import {
warehouseInfo,
InRecordBatchCheckPrintApi,
factoryWarehouseInventoryPrint,
warehouseInRecordExport, getLocalFactoryList,
warehouseInRecordExport,
getLocalFactoryList,
} from '@/api/warehouse'
import { filePath } from '@/api/axios.ts'
import BigNumber from 'bignumber.js'
......@@ -799,7 +800,7 @@ const selectSku = ref('')
const userMark = ref(0)
const batchUserMark = ref(0)
const importUserMark = ref(0)
const treeData = ref<InterWarehouseTree[]>()
const treeData = ref<InterWarehouseTree[]>([])
const [searchForm, resetSearchForm] = useValue<warehouseSearchForm>({})
const tradingTime = ref<string[]>([])
......@@ -847,12 +848,14 @@ const {
pageSize,
).then((res) => res.data),
})
import { roleShow } from '@/utils/roleShow'
const getTreeNum = async () => {
try {
const res = await getInRecordStatusTree()
res.data = [{ code: 'all', name: '全部', children: res.data }]
treeData.value = res.data
const result = res.data.filter((item: InterWarehouseTree) =>
roleShow(item.code),
)
treeData.value = [{ code: 'all', name: '全部', children: result }]
await nextTick(() => {
treeRef.value!.setCurrentKey(nodeCode.value, true)
})
......@@ -910,7 +913,6 @@ async function handlePrintProductTag() {
window.open(filePath + res.message, '_blank')
}
interface InterImportData {
warehouseSku: string
remark?: string | null
......
......@@ -96,7 +96,9 @@ const stockProductsColumns = computed(() => {
width: 100,
align: 'center',
render: (item: StockingApplyOrderDetailList) => (
<ImageView src={item.warehouseSkuImage} width="50px" height="50px" />
<div class="sku-image-cell">
<ImageView src={item.warehouseSkuImage} width="50px" height="50px" />
</div>
),
},
{
......@@ -260,4 +262,12 @@ const handleTabClick = (tab: TabsPaneContext) => {
color: #909399;
font-size: 14px;
}
.sku-image-cell {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>
......@@ -271,13 +271,15 @@ const loadWarehouseList = async () => {
console.error(e)
}
}
import { roleShow } from '@/utils/roleShow'
const loadTreeData = async () => {
try {
const res = await getStockingApplyOrderTreeApi()
if (res.code !== 200) return
const result = res.data.filter((item) => roleShow(item.code)) as TreeData[]
treeData.value = [
{ code: '-1', name: '全部', children: res.data || [] } as TreeData,
]
{ code: '-1', name: '全部', children: result },
] as typeof treeData.value
await nextTick(() => {
treeRef.value!.setCurrentKey(status.value, true)
})
......
......@@ -1165,7 +1165,7 @@ function getStartTime() {
return `${year}-${month}-${day} 00:00:00`
}
const selectSku = ref('')
const treeData = ref<InterWarehouseTree[]>()
const treeData = ref<InterWarehouseTree[]>([])
const [searchForm, resetSearchForm] = useValue<stockingPlanSearchForm>({
dateStr: 'createTime',
})
......@@ -1227,11 +1227,14 @@ const setCostPrice = (item: InterProductList) => {
item.totalPrice = Number(amount)
}
}
import { roleShow } from '@/utils/roleShow'
const getTreeNum = async () => {
try {
const res = await getStackingPlanStatusTree()
res.data = [{ code: 'all', name: '全部', children: res.data }]
treeData.value = res.data
const result = res.data.filter((item: InterWarehouseTree) =>
roleShow(item.code),
)
treeData.value = [{ code: 'all', name: '全部', children: result }]
await nextTick(() => {
treeRef.value!.setCurrentKey(nodeCode.value, true)
})
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment