<!-- 菜单管理页面 -->
|
<template>
|
<div class="menu-page art-full-height">
|
<ArtSearchBar
|
v-model="formFilters"
|
:items="formItems"
|
:showExpand="false"
|
@reset="handleReset"
|
@search="handleSearch"
|
/>
|
|
<ElCard class="art-table-card">
|
<ArtTableHeader
|
:showZebra="false"
|
:loading="loading"
|
v-model:columns="columnChecks"
|
@refresh="handleRefresh"
|
>
|
<template #left>
|
<ElButton v-auth="'add'" @click="handleAddMenu" v-ripple>添加菜单</ElButton>
|
<ElButton @click="toggleExpand" v-ripple>
|
{{ isExpanded ? '收起' : '展开' }}
|
</ElButton>
|
</template>
|
</ArtTableHeader>
|
|
<ArtTable
|
ref="tableRef"
|
rowKey="id"
|
:loading="loading"
|
:columns="columns"
|
:data="filteredTableData"
|
:stripe="false"
|
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
:default-expand-all="false"
|
/>
|
|
<MenuDialog
|
v-model:visible="dialogVisible"
|
:type="dialogType"
|
:editData="editData"
|
:lockType="lockMenuType"
|
:menuTreeOptions="menuTreeOptions"
|
@submit="handleSubmit"
|
/>
|
</ElCard>
|
</div>
|
</template>
|
|
<script setup>
|
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 { useTableColumns } from '@/hooks/core/useTableColumns'
|
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(true)
|
const tableData = ref([])
|
const menuTreeOptions = ref([])
|
|
const initialSearchState = {
|
name: '',
|
route: ''
|
}
|
|
const formFilters = reactive({ ...initialSearchState })
|
const appliedFilters = reactive({ ...initialSearchState })
|
|
const formItems = computed(() => [
|
{
|
label: '菜单名称',
|
key: 'name',
|
type: 'input',
|
props: { clearable: true }
|
},
|
{
|
label: '路由地址',
|
key: 'route',
|
type: 'input',
|
props: { clearable: true }
|
}
|
])
|
|
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 = Array.isArray(list) ? list : []
|
menuTreeOptions.value = [
|
{
|
label: '顶级菜单',
|
value: 0,
|
children: normalizeMenuTreeOptions(tableData.value)
|
}
|
]
|
} catch (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 || 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 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 closeDialog = () => {
|
dialogVisible.value = false
|
editData.value = null
|
}
|
|
const handleAddMenu = () => {
|
dialogType.value = 'menu'
|
editData.value = null
|
lockMenuType.value = true
|
dialogVisible.value = true
|
}
|
|
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 = 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) => {
|
const payload = buildMenuSubmitPayload(formData)
|
if (payload.id && payload.id === payload.parentId) {
|
ElMessage.error('上级菜单不能选择当前菜单')
|
return
|
}
|
|
try {
|
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('删除成功')
|
await loadMenuResources()
|
} catch (error) {
|
if (error !== 'cancel') {
|
ElMessage.error(error?.message || '删除失败')
|
}
|
}
|
}
|
|
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(() => {
|
if (tableRef.value?.elTableRef && filteredTableData.value) {
|
const processRows = (rows) => {
|
rows.forEach((row) => {
|
if (row.children?.length) {
|
tableRef.value.elTableRef.toggleRowExpansion(row, isExpanded.value)
|
processRows(row.children)
|
}
|
})
|
}
|
processRows(filteredTableData.value)
|
}
|
})
|
}
|
</script>
|