| | |
| | | import MenuDialog from './modules/menu-dialog.vue' |
| | | |
| | | import { formatMenuTitle } from '@/utils/router' |
| | | import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue' |
| | | import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue' |
| | | import { guardRequestWithMessage } from '@/utils/sys/requestGuard' |
| | | import { useTableColumns } from '@/hooks/core/useTableColumns' |
| | | import { |
| | | fetchDeleteMenu, |
| | |
| | | fetchSaveMenu, |
| | | fetchUpdateMenu |
| | | } from '@/api/system-manage' |
| | | import { ElTag, ElMessage, ElMessageBox } from 'element-plus' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { createMenuTableColumns } from './menuTable.columns' |
| | | import { |
| | | buildMenuSubmitPayload, |
| | | buildMenuTreeOptions, |
| | | createMenuSearchState, |
| | | expandMenuAuthChildren, |
| | | filterMenuTree, |
| | | getMenuDisplayTitle |
| | | } from './menuPage.helpers' |
| | | |
| | | defineOptions({ name: 'Menus' }) |
| | | |
| | |
| | | const tableData = ref([]) |
| | | const menuTreeOptions = ref([]) |
| | | |
| | | const initialSearchState = { |
| | | name: '', |
| | | route: '' |
| | | } |
| | | const initialSearchState = createMenuSearchState() |
| | | |
| | | const formFilters = reactive({ ...initialSearchState }) |
| | | const appliedFilters = reactive({ ...initialSearchState }) |
| | |
| | | } |
| | | ]) |
| | | |
| | | const normalizeNumber = (value, fallback = 0) => { |
| | | if (value === '' || value === null || value === undefined) { |
| | | return fallback |
| | | } |
| | | const normalized = Number(value) |
| | | return Number.isNaN(normalized) ? fallback : normalized |
| | | } |
| | | |
| | | const normalizeMenuTreeOptions = (nodes = []) => { |
| | | if (!Array.isArray(nodes)) { |
| | | return [] |
| | | } |
| | | |
| | | return nodes |
| | | .map((node) => ({ |
| | | label: formatMenuTitle(node.meta?.title || node.name || ''), |
| | | value: normalizeNumber(node.id, 0), |
| | | children: normalizeMenuTreeOptions(node.children) |
| | | })) |
| | | } |
| | | |
| | | const loadMenuResources = async () => { |
| | | loading.value = true |
| | | try { |
| | | const list = await fetchGetMenuList({}) |
| | | const list = await guardRequestWithMessage(fetchGetMenuList({}), null, { |
| | | timeoutMessage: '菜单加载超时,已停止等待' |
| | | }) |
| | | if (list === null) { |
| | | tableData.value = [] |
| | | menuTreeOptions.value = [] |
| | | return |
| | | } |
| | | tableData.value = Array.isArray(list) ? list : [] |
| | | menuTreeOptions.value = [ |
| | | { |
| | | label: '顶级菜单', |
| | | value: 0, |
| | | children: normalizeMenuTreeOptions(tableData.value) |
| | | } |
| | | ] |
| | | menuTreeOptions.value = buildMenuTreeOptions(tableData.value, formatMenuTitle) |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '获取菜单失败') |
| | | } finally { |
| | |
| | | loadMenuResources() |
| | | }) |
| | | |
| | | const hasNestedMenus = (row) => Array.isArray(row.children) && row.children.some((child) => !child.meta?.isAuthButton) |
| | | |
| | | const getMenuTypeTag = (row) => { |
| | | if (row.meta?.isAuthButton || Number(row.type) === 1) return 'danger' |
| | | if (hasNestedMenus(row)) return 'info' |
| | | return 'primary' |
| | | } |
| | | |
| | | const getMenuTypeText = (row) => { |
| | | if (row.meta?.isAuthButton || Number(row.type) === 1) return '按钮' |
| | | if (hasNestedMenus(row)) return '目录' |
| | | return '菜单' |
| | | } |
| | | |
| | | const getStatusMeta = (status) => { |
| | | return normalizeNumber(status, 1) === 1 |
| | | ? { text: '启用', type: 'success' } |
| | | : { text: '禁用', type: 'danger' } |
| | | } |
| | | |
| | | const getMenuDisplayTitle = (row) => { |
| | | const titleKey = row.meta?.title || row.name || '' |
| | | const normalizedTitleKey = |
| | | titleKey && !String(titleKey).includes('.') ? `menu.${titleKey}` : titleKey |
| | | |
| | | return formatMenuTitle(normalizedTitleKey) |
| | | } |
| | | |
| | | const getMenuDisplayIcon = (row) => row.meta?.icon || row.icon || '' |
| | | |
| | | const { columnChecks, columns } = useTableColumns(() => [ |
| | | { |
| | | prop: 'meta.title', |
| | | label: '菜单名称', |
| | | minWidth: 180, |
| | | formatter: (row) => getMenuDisplayTitle(row) |
| | | }, |
| | | { |
| | | prop: 'meta.icon', |
| | | label: '图标预览', |
| | | width: 96, |
| | | align: 'center', |
| | | formatter: (row) => { |
| | | const icon = getMenuDisplayIcon(row) |
| | | |
| | | if (!icon) return h('span', { class: 'text-g-400' }, '-') |
| | | |
| | | return h( |
| | | 'div', |
| | | { |
| | | class: |
| | | 'mx-auto flex h-8 w-8 items-center justify-center rounded-md border border-[var(--art-border-color)] bg-[var(--art-main-bg-color)]' |
| | | }, |
| | | [h(ArtSvgIcon, { icon, class: 'text-base text-g-700' })] |
| | | ) |
| | | } |
| | | }, |
| | | { |
| | | prop: 'type', |
| | | label: '菜单类型', |
| | | width: 110, |
| | | formatter: (row) => |
| | | h(ElTag, { type: getMenuTypeTag(row), effect: 'light' }, () => getMenuTypeText(row)) |
| | | }, |
| | | { |
| | | prop: 'route', |
| | | label: '路由', |
| | | minWidth: 180, |
| | | formatter: (row) => { |
| | | if (row.meta?.isAuthButton) return '' |
| | | return row.route || '' |
| | | } |
| | | }, |
| | | { |
| | | prop: 'authority', |
| | | label: '权限标识', |
| | | minWidth: 180, |
| | | formatter: (row) => { |
| | | if (row.meta?.isAuthButton) { |
| | | return row.authority || row.meta?.authMark || '' |
| | | } |
| | | if (!row.meta?.authList?.length) return row.authority || '' |
| | | return `${row.meta.authList.length} 个权限标识` |
| | | } |
| | | }, |
| | | { |
| | | prop: 'sort', |
| | | label: '排序', |
| | | width: 90 |
| | | }, |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | width: 100, |
| | | formatter: (row) => { |
| | | const statusMeta = getStatusMeta(row.status) |
| | | return h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text) |
| | | } |
| | | }, |
| | | { |
| | | prop: 'memo', |
| | | label: '备注', |
| | | minWidth: 180, |
| | | showOverflowTooltip: true, |
| | | formatter: (row) => row.memo || '-' |
| | | }, |
| | | { |
| | | prop: 'operation', |
| | | label: '操作', |
| | | width: 180, |
| | | align: 'right', |
| | | formatter: (row) => { |
| | | const buttonStyle = { class: 'flex justify-end' } |
| | | if (row.meta?.isAuthButton) { |
| | | return h('div', buttonStyle, [ |
| | | h(ArtButtonTable, { |
| | | type: 'edit', |
| | | onClick: () => handleEditAuth(row) |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => handleDeleteAuth(row) |
| | | }) |
| | | ]) |
| | | } |
| | | return h('div', buttonStyle, [ |
| | | h(ArtButtonTable, { |
| | | type: 'add', |
| | | onClick: () => handleAddAuth(row), |
| | | title: '新增权限' |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'edit', |
| | | onClick: () => handleEditMenu(row) |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => handleDeleteMenu(row) |
| | | }) |
| | | ]) |
| | | } |
| | | }, |
| | | ]) |
| | | |
| | | const deepClone = (obj) => { |
| | | if (obj === null || typeof obj !== 'object') return obj |
| | | if (obj instanceof Date) return new Date(obj) |
| | | if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) |
| | | const cloned = {} |
| | | for (const key in obj) { |
| | | if (Object.prototype.hasOwnProperty.call(obj, key)) { |
| | | cloned[key] = deepClone(obj[key]) |
| | | } |
| | | } |
| | | return cloned |
| | | } |
| | | |
| | | const convertAuthListToChildren = (items) => { |
| | | return items.map((item) => { |
| | | const clonedItem = deepClone(item) |
| | | if (clonedItem.children?.length) { |
| | | clonedItem.children = convertAuthListToChildren(clonedItem.children) |
| | | } |
| | | if (item.meta?.authList?.length) { |
| | | const authChildren = item.meta.authList.map((auth) => ({ |
| | | ...deepClone(auth), |
| | | route: auth.route || '', |
| | | component: auth.component || '', |
| | | meta: { |
| | | title: auth.title, |
| | | authMark: auth.authMark, |
| | | isAuthButton: true, |
| | | parentPath: item.path, |
| | | icon: auth.icon, |
| | | sort: auth.sort, |
| | | isEnable: normalizeNumber(auth.status, 1) === 1 |
| | | } |
| | | })) |
| | | clonedItem.children = clonedItem.children?.length |
| | | ? [...clonedItem.children, ...authChildren] |
| | | : authChildren |
| | | } |
| | | return clonedItem |
| | | const { columnChecks, columns } = useTableColumns(() => |
| | | createMenuTableColumns({ |
| | | titleFormatter: formatMenuTitle, |
| | | handleAddAuth, |
| | | handleEditAuth, |
| | | handleDeleteAuth, |
| | | handleEditMenu, |
| | | handleDeleteMenu |
| | | }) |
| | | } |
| | | |
| | | const searchMenu = (items) => { |
| | | const results = [] |
| | | for (const item of items) { |
| | | const searchName = appliedFilters.name?.toLowerCase().trim() || '' |
| | | const searchRoute = appliedFilters.route?.toLowerCase().trim() || '' |
| | | const menuTitle = getMenuDisplayTitle(item).toLowerCase() |
| | | const menuRoute = String(item.route || item.path || item.authority || '').toLowerCase() |
| | | const nameMatch = !searchName || menuTitle.includes(searchName) |
| | | const routeMatch = !searchRoute || menuRoute.includes(searchRoute) |
| | | |
| | | if (item.children?.length) { |
| | | const matchedChildren = searchMenu(item.children) |
| | | if (matchedChildren.length > 0) { |
| | | const clonedItem = deepClone(item) |
| | | clonedItem.children = matchedChildren |
| | | results.push(clonedItem) |
| | | continue |
| | | } |
| | | } |
| | | |
| | | if (nameMatch && routeMatch) { |
| | | results.push(deepClone(item)) |
| | | } |
| | | } |
| | | return results |
| | | } |
| | | ) |
| | | |
| | | const filteredTableData = computed(() => { |
| | | const searchedData = searchMenu(tableData.value) |
| | | return convertAuthListToChildren(searchedData) |
| | | const searchedData = filterMenuTree(tableData.value, appliedFilters, formatMenuTitle) |
| | | return expandMenuAuthChildren(searchedData) |
| | | }) |
| | | |
| | | const closeDialog = () => { |
| | | function closeDialog() { |
| | | dialogVisible.value = false |
| | | editData.value = null |
| | | } |
| | | |
| | | const handleAddMenu = () => { |
| | | function handleAddMenu() { |
| | | dialogType.value = 'menu' |
| | | editData.value = null |
| | | lockMenuType.value = true |
| | | dialogVisible.value = true |
| | | } |
| | | |
| | | const handleAddAuth = (row) => { |
| | | function handleAddAuth(row) { |
| | | dialogType.value = 'button' |
| | | editData.value = { |
| | | parentId: row.id, |
| | |
| | | dialogVisible.value = true |
| | | } |
| | | |
| | | const handleEditMenu = (row) => { |
| | | function handleEditMenu(row) { |
| | | dialogType.value = 'menu' |
| | | editData.value = row |
| | | lockMenuType.value = true |
| | | dialogVisible.value = true |
| | | } |
| | | |
| | | const handleEditAuth = (row) => { |
| | | function handleEditAuth(row) { |
| | | dialogType.value = 'button' |
| | | editData.value = row |
| | | lockMenuType.value = true |
| | | dialogVisible.value = true |
| | | } |
| | | |
| | | const buildMenuSubmitPayload = (formData) => { |
| | | return { |
| | | ...(formData.id ? { id: normalizeNumber(formData.id, 0) } : {}), |
| | | parentId: normalizeNumber(formData.parentId, 0), |
| | | name: String(formData.name || '').trim(), |
| | | route: String(formData.route || '').trim(), |
| | | component: String(formData.component || '').trim(), |
| | | authority: String(formData.authority || '').trim(), |
| | | icon: String(formData.icon || '').trim(), |
| | | sort: normalizeNumber(formData.sort, 0), |
| | | status: normalizeNumber(formData.status, 1), |
| | | memo: String(formData.memo || '').trim(), |
| | | type: formData.menuType === 'button' ? 1 : 0 |
| | | } |
| | | } |
| | | |
| | | const handleSubmit = async (formData) => { |
| | | async function handleSubmit(formData) { |
| | | const payload = buildMenuSubmitPayload(formData) |
| | | if (payload.id && payload.id === payload.parentId) { |
| | | ElMessage.error('上级菜单不能选择当前菜单') |
| | |
| | | } |
| | | } |
| | | |
| | | const handleDeleteMenu = async (row) => { |
| | | async function handleDeleteMenu(row) { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `确定要删除菜单「${formatMenuTitle(row.meta?.title || row.name || '')}」吗?删除后无法恢复`, |
| | | `确定要删除菜单「${getMenuDisplayTitle(row, formatMenuTitle)}」吗?删除后无法恢复`, |
| | | '删除确认', |
| | | { |
| | | confirmButtonText: '确定', |
| | |
| | | } |
| | | } |
| | | |
| | | const handleDeleteAuth = async (row) => { |
| | | async function handleDeleteAuth(row) { |
| | | try { |
| | | await ElMessageBox.confirm(`确定要删除权限「${row.name || row.authority || row.id}」吗?删除后无法恢复`, '删除确认', { |
| | | confirmButtonText: '确定', |
| | |
| | | } |
| | | } |
| | | |
| | | const handleReset = () => { |
| | | function handleReset() { |
| | | Object.assign(formFilters, { ...initialSearchState }) |
| | | Object.assign(appliedFilters, { ...initialSearchState }) |
| | | loadMenuResources() |
| | | } |
| | | |
| | | const handleSearch = () => { |
| | | function handleSearch() { |
| | | Object.assign(appliedFilters, { ...formFilters }) |
| | | } |
| | | |
| | | const handleRefresh = () => { |
| | | function handleRefresh() { |
| | | loadMenuResources() |
| | | } |
| | | |
| | | const toggleExpand = () => { |
| | | function toggleExpand() { |
| | | isExpanded.value = !isExpanded.value |
| | | nextTick(() => { |
| | | if (tableRef.value?.elTableRef && filteredTableData.value) { |