front/src/views/system/permission/menu/index.vue
2026-02-24 18:33:14 +08:00

445 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="menu-page">
<div class="page-header">
<a-space>
<a-button
v-if="hasPermission('Rbac.Menus.Create')"
type="primary"
@click="handleAdd()"
>
<template #icon><icon-plus /></template>
新增菜单
</a-button>
</a-space>
<a-space>
<a-button @click="loadData">
<template #icon><icon-refresh /></template>
</a-button>
</a-space>
</div>
<a-table
:columns="columns"
:data="dataSource"
:loading="loading"
:pagination="false"
:default-expand-all="false"
:expanded-row-keys="expandedKeys"
@expand="handleExpand"
row-key="id"
>
<template #type="{ record }">
<a-tag :color="record.type === 1 ? 'blue' : 'green'">
{{ record.type === 1 ? t('system.menu.type.menu') : t('system.menu.type.button') }}
</a-tag>
</template>
<template #icon="{ record }">
<component v-if="record.icon" :is="getIcon(record.icon)" />
</template>
<template #isHidden="{ record }">
<a-tag :color="record.isHidden ? 'red' : 'green'">
{{ record.isHidden ? t('system.menu.status.hide') : t('system.menu.status.show') }}
</a-tag>
</template>
<template #action="{ record }">
<div class="action-buttons">
<a-button
v-if="hasPermission('Rbac.Menus.Create')"
type="text"
size="small"
class="action-btn add-btn"
@click="handleAdd(record.id)"
:title="t('system.menu.actions.addChild')"
>
<IconPlus />
</a-button>
<a-button
v-if="hasPermission('Rbac.Menus.Update')"
type="text"
size="small"
class="action-btn edit-btn"
@click="handleEdit(record)"
title="编辑"
>
<IconEdit />
</a-button>
<a-popconfirm
v-if="hasPermission('Rbac.Menus.Delete')"
:content="t('system.menu.modal.deleteConfirmContent', { count: 1 })"
@ok="handleDelete(record.id)"
>
<a-button
type="text"
size="small"
class="action-btn delete-btn"
title="删除"
>
<IconDelete />
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
<!-- 新增/编辑菜单弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
@ok="handleModalOk"
width="700px"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item label="父级菜单" field="pId">
<a-tree-select
v-model="formState.pId"
:tree-data="menuTreeData"
placeholder="请选择父级菜单(不选则为顶级菜单)"
allow-clear
:field-names="{ label: 'name', value: 'id', children: 'children' }"
/>
</a-form-item>
<a-form-item label="菜单类型" field="type">
<a-radio-group v-model="formState.type">
<a-radio :value="1">菜单</a-radio>
<a-radio :value="2">按钮</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="菜单名称" field="name">
<a-input v-model="formState.name" placeholder="请输入菜单名称" />
</a-form-item>
<a-form-item label="权限编码" field="permissionCode">
<a-input v-model="formState.permissionCode" placeholder="请输入权限编码" />
</a-form-item>
<a-form-item v-if="formState.type === 1" label="图标" field="icon">
<a-input v-model="formState.icon" placeholder="请输入图标名称" />
</a-form-item>
<a-form-item v-if="formState.type === 1" label="路由" field="route">
<a-input v-model="formState.route" placeholder="请输入路由路径" />
</a-form-item>
<a-form-item v-if="formState.type === 1" label="组件" field="component">
<a-input v-model="formState.component" placeholder="请输入组件路径" />
</a-form-item>
<a-form-item label="排序" field="order">
<a-input-number
v-model="formState.order"
:min="0"
style="width: 100%"
/>
</a-form-item>
<a-form-item v-if="formState.type === 1" label="是否隐藏" field="isHidden">
<a-switch v-model="formState.isHidden" />
</a-form-item>
<a-form-item v-if="formState.type === 1" label="是否缓存" field="isCached">
<a-switch v-model="formState.isCached" />
</a-form-item>
<a-form-item label="描述" field="description">
<a-textarea
v-model="formState.description"
placeholder="请输入描述"
:auto-size="{ minRows: 3, maxRows: 3 }"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus, IconRefresh, IconEdit, IconDelete } from '@arco-design/web-vue/es/icon'
import { MenuService } from '@/api/rbac'
import type { MenuDto, MenuTreeDto, MenuCreateDto, MenuUpdateDto, MenuType } from '@/api/rbac'
import { hasPermission } from '@/utils/rbac-permission'
import { handleApiError } from '@/utils/rbac-error-handler'
import * as Icons from '@arco-design/web-vue/es/icon'
const { t } = useI18n()
const loading = ref(false)
const dataSource = ref<MenuTreeDto[]>([])
const expandedKeys = ref<string[]>([])
const columns = computed(() => [
{ title: t('system.menu.table.title'), dataIndex: 'name', width: 200 },
{ title: t('system.menu.table.menuType'), slotName: 'type', width: 100 },
{ title: t('system.menu.table.icon'), slotName: 'icon', width: 80 },
{ title: t('system.menu.table.code'), dataIndex: 'permissionCode' },
{ title: t('system.menu.table.path'), dataIndex: 'route' },
{ title: t('system.menu.table.sortCode'), dataIndex: 'order', width: 80 },
{ title: t('system.menu.table.status'), slotName: 'isHidden', width: 80 },
{ title: t('system.menu.table.actions'), slotName: 'action', width: 280 },
])
// 表单相关
const modalVisible = ref(false)
const modalLoading = ref(false)
const modalTitle = computed(() => isEdit.value ? t('system.menu.modal.edit') : t('system.menu.modal.add'))
const isEdit = ref(false)
const formRef = ref()
const currentId = ref('')
const formState = reactive<MenuCreateDto>({
pId: undefined,
name: '',
type: 1, // MenuType = 1 | 2
permissionCode: '',
icon: '',
route: '',
component: '',
order: 0,
isHidden: false,
isCached: false,
description: '',
})
const formRules = {
name: [{ required: true, message: '请输入菜单名称' }],
type: [{ required: true, message: '请选择菜单类型' }],
}
const menuTreeData = computed(() => dataSource.value)
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
dataSource.value = await MenuService.getRbacMenuGetMenuTree()
// 默认展开前两级
expandedKeys.value = getDefaultExpandedKeys(dataSource.value, 2)
} catch (error) {
handleApiError(error)
} finally {
loading.value = false
}
}
// 获取默认展开的节点(展开前 N 级)
function getDefaultExpandedKeys(data: MenuTreeDto[], maxLevel: number, currentLevel = 1): string[] {
const keys: string[] = []
if (currentLevel > maxLevel) return keys
data.forEach((item) => {
if (item.id && item.children && item.children.length > 0) {
keys.push(item.id)
// 递归获取子节点
keys.push(...getDefaultExpandedKeys(item.children, maxLevel, currentLevel + 1))
}
})
return keys
}
// 处理展开/收起
function handleExpand(rowKey: string, record: MenuTreeDto) {
const index = expandedKeys.value.indexOf(rowKey)
if (index > -1) {
// 收起时,同时收起所有子节点
const keysToRemove = new Set<string>([rowKey])
const collectChildKeys = (item: MenuTreeDto) => {
if (item.children) {
item.children.forEach((child) => {
if (child.id) {
keysToRemove.add(child.id)
collectChildKeys(child)
}
})
}
}
collectChildKeys(record)
expandedKeys.value = expandedKeys.value.filter((key) => !keysToRemove.has(key))
} else {
// 展开
expandedKeys.value.push(rowKey)
}
}
function handleAdd(parentId?: string) {
modalTitle.value = '新增菜单'
isEdit.value = false
Object.assign(formState, {
pId: parentId,
name: '',
type: 1 as MenuType,
permissionCode: '',
icon: '',
route: '',
component: '',
order: 0,
isHidden: false,
isCached: false,
description: '',
})
modalVisible.value = true
}
function handleEdit(record: MenuDto) {
isEdit.value = true
currentId.value = record.id || ''
Object.assign(formState, {
pId: record.pId,
name: record.name,
type: record.type,
permissionCode: record.permissionCode,
icon: record.icon,
route: record.route,
component: record.component,
order: record.order,
isHidden: record.isHidden,
isCached: record.isCached,
description: record.description,
})
modalVisible.value = true
}
// 清理空字符串,转换为undefined(API不接受空字符串)
function cleanMenuData(data: MenuCreateDto): MenuCreateDto {
return {
pId: data.pId || undefined,
name: data.name,
type: data.type,
permissionCode: data.permissionCode && data.permissionCode.trim() ? data.permissionCode : undefined,
icon: data.icon && data.icon.trim() ? data.icon : undefined,
route: data.route && data.route.trim() ? data.route : undefined,
component: data.component && data.component.trim() ? data.component : undefined,
order: data.order ?? 0,
isHidden: data.isHidden ?? false,
isCached: data.isCached ?? false,
description: data.description && data.description.trim() ? data.description : undefined,
}
}
async function handleModalOk() {
try {
await formRef.value.validate()
modalLoading.value = true
if (isEdit.value) {
const updateData: MenuUpdateDto = {
name: formState.name,
permissionCode: formState.permissionCode || undefined,
icon: formState.icon || undefined,
route: formState.route || undefined,
component: formState.component || undefined,
order: formState.order ?? undefined,
isHidden: formState.isHidden,
isCached: formState.isCached,
description: formState.description || undefined,
}
await MenuService.putRbacMenuUpdate(currentId.value!, updateData)
Message.success('更新成功')
} else {
const createData = cleanMenuData(formState)
await MenuService.postRbacMenuCreate(createData)
Message.success('创建成功')
}
modalVisible.value = false
loadData()
} catch (error: any) {
if (error.fields) {
return
}
handleApiError(error)
} finally {
modalLoading.value = false
}
}
async function handleDelete(id: string | undefined) {
if (!id) return
try {
await MenuService.deleteRbacMenuDelete(id)
Message.success('删除成功')
loadData()
} catch (error) {
handleApiError(error)
}
}
function getIcon(iconName: string) {
return (Icons as any)[iconName] || null
}
</script>
<style scoped>
.menu-page {
padding: 24px;
background: #fff;
border-radius: 8px;
}
.page-header {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
.action-btn {
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
&.add-btn {
color: var(--color-text-2);
&:hover {
background-color: var(--color-fill-2);
color: var(--color-text-1);
}
}
&.edit-btn {
color: var(--color-text-2);
&:hover {
background-color: var(--color-fill-2);
color: var(--color-text-1);
}
}
&.delete-btn {
color: #ff4d4f;
&:hover {
background-color: #fff2f0;
color: #ff7875;
}
}
}
}
</style>