| | |
| | | <!-- 菜单管理页面 --> |
| | | <template> |
| | | <div class="menu-page art-full-height"> |
| | | <!-- 搜索栏 --> |
| | | <ArtSearchBar |
| | | v-model="formFilters" |
| | | :items="formItems" |
| | |
| | | /> |
| | | |
| | | <ElCard class="art-table-card"> |
| | | <!-- 表格头部 --> |
| | | <ArtTableHeader |
| | | :showZebra="false" |
| | | :loading="loading" |
| | |
| | | @refresh="handleRefresh" |
| | | > |
| | | <template #left> |
| | | <ElButton v-auth="'add'" @click="handleAddMenu" v-ripple> 添加菜单 </ElButton> |
| | | <ElButton v-auth="'add'" @click="handleAddMenu" v-ripple>添加菜单</ElButton> |
| | | <ElButton @click="toggleExpand" v-ripple> |
| | | {{ isExpanded ? '收起' : '展开' }} |
| | | </ElButton> |
| | |
| | | |
| | | <ArtTable |
| | | ref="tableRef" |
| | | rowKey="path" |
| | | rowKey="id" |
| | | :loading="loading" |
| | | :columns="columns" |
| | | :data="filteredTableData" |
| | |
| | | :default-expand-all="false" |
| | | /> |
| | | |
| | | <!-- 菜单弹窗 --> |
| | | <MenuDialog |
| | | v-model:visible="dialogVisible" |
| | | :type="dialogType" |
| | | :editData="editData" |
| | | :lockType="lockMenuType" |
| | | :menuTreeOptions="menuTreeOptions" |
| | | @submit="handleSubmit" |
| | | /> |
| | | </ElCard> |
| | |
| | | import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue' |
| | | import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue' |
| | | import { useTableColumns } from '@/hooks/core/useTableColumns' |
| | | import { fetchGetMenuList } from '@/api/system-manage' |
| | | import { ElTag, ElMessageBox } from 'element-plus' |
| | | import { |
| | | fetchDeleteMenu, |
| | | fetchGetMenuList, |
| | | fetchSaveMenu, |
| | | fetchUpdateMenu |
| | | } from '@/api/system-manage' |
| | | import { ElTag, ElMessage, ElMessageBox } from 'element-plus' |
| | | |
| | | defineOptions({ name: 'Menus' }) |
| | | |
| | | const loading = ref(false) |
| | | const isExpanded = ref(false) |
| | | const tableRef = ref() |
| | | const dialogVisible = ref(false) |
| | | const dialogType = ref('menu') |
| | | const editData = ref(null) |
| | | const lockMenuType = ref(false) |
| | | const lockMenuType = ref(true) |
| | | const tableData = ref([]) |
| | | const menuTreeOptions = ref([]) |
| | | |
| | | const initialSearchState = { |
| | | name: '', |
| | | route: '' |
| | | } |
| | | |
| | | const formFilters = reactive({ ...initialSearchState }) |
| | | const appliedFilters = reactive({ ...initialSearchState }) |
| | | |
| | | const formItems = computed(() => [ |
| | | { |
| | | label: '菜单名称', |
| | |
| | | props: { clearable: true } |
| | | } |
| | | ]) |
| | | onMounted(() => { |
| | | getMenuList() |
| | | }) |
| | | const getMenuList = async () => { |
| | | |
| | | 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() |
| | | tableData.value = list |
| | | const list = await fetchGetMenuList({}) |
| | | tableData.value = Array.isArray(list) ? list : [] |
| | | menuTreeOptions.value = [ |
| | | { |
| | | label: '顶级菜单', |
| | | value: 0, |
| | | children: normalizeMenuTreeOptions(tableData.value) |
| | | } |
| | | ] |
| | | } catch (error) { |
| | | throw error instanceof Error ? error : new Error('获取菜单失败') |
| | | ElMessage.error(error?.message || '获取菜单失败') |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | loadMenuResources() |
| | | }) |
| | | |
| | | const hasNestedMenus = (row) => Array.isArray(row.children) && row.children.some((child) => !child.meta?.isAuthButton) |
| | | |
| | | const getMenuTypeTag = (row) => { |
| | | if (row.meta?.isAuthButton) return 'danger' |
| | | if (row.children?.length) return 'info' |
| | | if (row.meta?.link && row.meta?.isIframe) return 'success' |
| | | if (row.path) return 'primary' |
| | | if (row.meta?.link) return 'warning' |
| | | return 'info' |
| | | if (row.meta?.isAuthButton || Number(row.type) === 1) return 'danger' |
| | | if (hasNestedMenus(row)) return 'info' |
| | | return 'primary' |
| | | } |
| | | |
| | | const getMenuTypeText = (row) => { |
| | | if (row.meta?.isAuthButton) return '按钮' |
| | | if (row.children?.length) return '目录' |
| | | if (row.meta?.link && row.meta?.isIframe) return '内嵌' |
| | | if (row.path) return '菜单' |
| | | if (row.meta?.link) return '外链' |
| | | return '未知' |
| | | 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: '图标预览', |
| | |
| | | |
| | | if (!icon) return h('span', { class: 'text-g-400' }, '-') |
| | | |
| | | return h('div', { class: 'flex items-center justify-center' }, [ |
| | | h(ArtSvgIcon, { icon, class: 'text-base text-g-700' }) |
| | | ]) |
| | | 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: 'meta.title', |
| | | label: '菜单名称', |
| | | minWidth: 120, |
| | | formatter: (row) => getMenuDisplayTitle(row) |
| | | }, |
| | | { |
| | | prop: 'type', |
| | | label: '菜单类型', |
| | | formatter: (row) => { |
| | | return h(ElTag, { type: getMenuTypeTag(row) }, () => getMenuTypeText(row)) |
| | | } |
| | | width: 110, |
| | | formatter: (row) => |
| | | h(ElTag, { type: getMenuTypeTag(row), effect: 'light' }, () => getMenuTypeText(row)) |
| | | }, |
| | | { |
| | | prop: 'path', |
| | | prop: 'route', |
| | | label: '路由', |
| | | minWidth: 180, |
| | | formatter: (row) => { |
| | | if (row.meta?.isAuthButton) return '' |
| | | return row.meta?.link || row.path || '' |
| | | return row.route || '' |
| | | } |
| | | }, |
| | | { |
| | | prop: 'meta.authList', |
| | | prop: 'authority', |
| | | label: '权限标识', |
| | | minWidth: 180, |
| | | formatter: (row) => { |
| | | if (row.meta?.isAuthButton) { |
| | | return row.meta?.authMark || '' |
| | | return row.authority || row.meta?.authMark || '' |
| | | } |
| | | if (!row.meta?.authList?.length) return '' |
| | | if (!row.meta?.authList?.length) return row.authority || '' |
| | | return `${row.meta.authList.length} 个权限标识` |
| | | } |
| | | }, |
| | | { |
| | | prop: 'date', |
| | | label: '编辑时间', |
| | | formatter: () => '2022-3-12 12:00:00' |
| | | prop: 'sort', |
| | | label: '排序', |
| | | width: 90 |
| | | }, |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | formatter: () => h(ElTag, { type: 'success' }, () => '启用') |
| | | 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', |
| | |
| | | width: 180, |
| | | align: 'right', |
| | | formatter: (row) => { |
| | | const buttonStyle = { style: 'text-align: right' } |
| | | const buttonStyle = { class: 'flex justify-end' } |
| | | if (row.meta?.isAuthButton) { |
| | | return h('div', buttonStyle, [ |
| | | h(ArtButtonTable, { |
| | |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => handleDeleteAuth() |
| | | onClick: () => handleDeleteAuth(row) |
| | | }) |
| | | ]) |
| | | } |
| | | return h('div', buttonStyle, [ |
| | | h(ArtButtonTable, { |
| | | type: 'add', |
| | | onClick: () => handleAddAuth(), |
| | | onClick: () => handleAddAuth(row), |
| | | title: '新增权限' |
| | | }), |
| | | h(ArtButtonTable, { |
| | |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => handleDeleteMenu() |
| | | onClick: () => handleDeleteMenu(row) |
| | | }) |
| | | ]) |
| | | } |
| | | } |
| | | }, |
| | | ]) |
| | | const tableData = ref([]) |
| | | const handleReset = () => { |
| | | Object.assign(formFilters, { ...initialSearchState }) |
| | | Object.assign(appliedFilters, { ...initialSearchState }) |
| | | getMenuList() |
| | | } |
| | | const handleSearch = () => { |
| | | Object.assign(appliedFilters, { ...formFilters }) |
| | | getMenuList() |
| | | } |
| | | const handleRefresh = () => { |
| | | getMenuList() |
| | | } |
| | | |
| | | const deepClone = (obj) => { |
| | | if (obj === null || typeof obj !== 'object') return obj |
| | | if (obj instanceof Date) return new Date(obj) |
| | |
| | | } |
| | | return cloned |
| | | } |
| | | |
| | | const convertAuthListToChildren = (items) => { |
| | | return items.map((item) => { |
| | | const clonedItem = deepClone(item) |
| | |
| | | } |
| | | if (item.meta?.authList?.length) { |
| | | const authChildren = item.meta.authList.map((auth) => ({ |
| | | path: `${item.path}_auth_${auth.authMark}`, |
| | | name: `${String(item.name)}_auth_${auth.authMark}`, |
| | | ...deepClone(auth), |
| | | route: auth.route || '', |
| | | component: auth.component || '', |
| | | meta: { |
| | | title: auth.title, |
| | | authMark: auth.authMark, |
| | | isAuthButton: true, |
| | | parentPath: item.path |
| | | parentPath: item.path, |
| | | icon: auth.icon, |
| | | sort: auth.sort, |
| | | isEnable: normalizeNumber(auth.status, 1) === 1 |
| | | } |
| | | })) |
| | | clonedItem.children = clonedItem.children?.length |
| | |
| | | return clonedItem |
| | | }) |
| | | } |
| | | |
| | | const searchMenu = (items) => { |
| | | const results = [] |
| | | 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 menuPath = (item.path || '').toLowerCase() |
| | | const menuRoute = String(item.route || item.path || item.authority || '').toLowerCase() |
| | | const nameMatch = !searchName || menuTitle.includes(searchName) |
| | | const routeMatch = !searchRoute || menuPath.includes(searchRoute) |
| | | const routeMatch = !searchRoute || menuRoute.includes(searchRoute) |
| | | |
| | | if (item.children?.length) { |
| | | const matchedChildren = searchMenu(item.children) |
| | | if (matchedChildren.length > 0) { |
| | |
| | | continue |
| | | } |
| | | } |
| | | |
| | | if (nameMatch && routeMatch) { |
| | | results.push(deepClone(item)) |
| | | } |
| | | } |
| | | return results |
| | | } |
| | | |
| | | const filteredTableData = computed(() => { |
| | | const searchedData = searchMenu(tableData.value) |
| | | return convertAuthListToChildren(searchedData) |
| | | }) |
| | | |
| | | const closeDialog = () => { |
| | | dialogVisible.value = false |
| | | editData.value = null |
| | | } |
| | | |
| | | const handleAddMenu = () => { |
| | | dialogType.value = 'menu' |
| | | editData.value = null |
| | | lockMenuType.value = true |
| | | dialogVisible.value = true |
| | | } |
| | | const handleAddAuth = () => { |
| | | dialogType.value = 'menu' |
| | | editData.value = null |
| | | lockMenuType.value = false |
| | | |
| | | const handleAddAuth = (row) => { |
| | | dialogType.value = 'button' |
| | | editData.value = { |
| | | parentId: row.id, |
| | | type: 1, |
| | | status: 1, |
| | | sort: 0 |
| | | } |
| | | lockMenuType.value = true |
| | | dialogVisible.value = true |
| | | } |
| | | |
| | | const handleEditMenu = (row) => { |
| | | dialogType.value = 'menu' |
| | | editData.value = row |
| | | lockMenuType.value = true |
| | | dialogVisible.value = true |
| | | } |
| | | |
| | | const handleEditAuth = (row) => { |
| | | dialogType.value = 'button' |
| | | editData.value = { |
| | | title: row.meta?.title, |
| | | authMark: row.meta?.authMark |
| | | } |
| | | lockMenuType.value = false |
| | | editData.value = row |
| | | lockMenuType.value = true |
| | | dialogVisible.value = true |
| | | } |
| | | const handleSubmit = (formData) => { |
| | | console.log('提交数据:', formData) |
| | | getMenuList() |
| | | |
| | | 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 handleDeleteMenu = async () => { |
| | | |
| | | const handleSubmit = async (formData) => { |
| | | const payload = buildMenuSubmitPayload(formData) |
| | | if (payload.id && payload.id === payload.parentId) { |
| | | ElMessage.error('上级菜单不能选择当前菜单') |
| | | return |
| | | } |
| | | |
| | | try { |
| | | await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', { |
| | | if (payload.id) { |
| | | await fetchUpdateMenu(payload) |
| | | ElMessage.success('修改成功') |
| | | } else { |
| | | await fetchSaveMenu(payload) |
| | | ElMessage.success('新增成功') |
| | | } |
| | | closeDialog() |
| | | await loadMenuResources() |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '提交失败') |
| | | } |
| | | } |
| | | |
| | | const handleDeleteMenu = async (row) => { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `确定要删除菜单「${formatMenuTitle(row.meta?.title || row.name || '')}」吗?删除后无法恢复`, |
| | | '删除确认', |
| | | { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | } |
| | | ) |
| | | await fetchDeleteMenu(row.id) |
| | | ElMessage.success('删除成功') |
| | | await loadMenuResources() |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | ElMessage.error(error?.message || '删除失败') |
| | | } |
| | | } |
| | | } |
| | | |
| | | const handleDeleteAuth = async (row) => { |
| | | try { |
| | | await ElMessageBox.confirm(`确定要删除权限「${row.name || row.authority || row.id}」吗?删除后无法恢复`, '删除确认', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | }) |
| | | await fetchDeleteMenu(row.id) |
| | | ElMessage.success('删除成功') |
| | | getMenuList() |
| | | await loadMenuResources() |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | ElMessage.error('删除失败') |
| | | ElMessage.error(error?.message || '删除失败') |
| | | } |
| | | } |
| | | } |
| | | const handleDeleteAuth = async () => { |
| | | try { |
| | | await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | }) |
| | | ElMessage.success('删除成功') |
| | | getMenuList() |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | ElMessage.error('删除失败') |
| | | } |
| | | } |
| | | |
| | | const handleReset = () => { |
| | | Object.assign(formFilters, { ...initialSearchState }) |
| | | Object.assign(appliedFilters, { ...initialSearchState }) |
| | | loadMenuResources() |
| | | } |
| | | |
| | | const handleSearch = () => { |
| | | Object.assign(appliedFilters, { ...formFilters }) |
| | | } |
| | | |
| | | const handleRefresh = () => { |
| | | loadMenuResources() |
| | | } |
| | | |
| | | const toggleExpand = () => { |
| | | isExpanded.value = !isExpanded.value |
| | | nextTick(() => { |