445 lines
12 KiB
Vue
445 lines
12 KiB
Vue
<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>
|
||
|