|
|
@@ -0,0 +1,339 @@
|
|
|
+<template>
|
|
|
+ <div class="dashboard-page">
|
|
|
+ <section class="section-card filter-card">
|
|
|
+ <div class="section-title">筛选条件</div>
|
|
|
+ <div class="filter-grid">
|
|
|
+ <label>
|
|
|
+ <span>菜单名称</span>
|
|
|
+ <a-input v-model="filters.menuName" allow-clear placeholder="请输入菜单名称" />
|
|
|
+ </label>
|
|
|
+ <label>
|
|
|
+ <span>权限标识</span>
|
|
|
+ <a-input v-model="filters.permission" allow-clear placeholder="请输入权限标识" />
|
|
|
+ </label>
|
|
|
+ <label>
|
|
|
+ <span>状态</span>
|
|
|
+ <a-select v-model="filters.status" allow-clear placeholder="请选择状态">
|
|
|
+ <a-option :value="1">正常</a-option>
|
|
|
+ <a-option :value="0">停用</a-option>
|
|
|
+ </a-select>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <div class="filter-actions">
|
|
|
+ <a-button type="primary" @click="applyFilters">查询</a-button>
|
|
|
+ <a-button @click="resetFilters">重置</a-button>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="section-card">
|
|
|
+ <div class="section-head">
|
|
|
+ <div class="section-title">菜单列表</div>
|
|
|
+ <div class="section-actions">
|
|
|
+ <a-link @click="toggleAllMenus">{{ allExpanded ? '折叠' : '展开' }}</a-link>
|
|
|
+ <a-button type="primary" class="section-add-button" @click="openCreate">
|
|
|
+ <template #icon>+</template>
|
|
|
+ 新增菜单
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <a-table :data="filteredMenus" :pagination="false" row-key="id" :bordered="false">
|
|
|
+ <template #columns>
|
|
|
+ <a-table-column title="菜单名称" :width="240">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <div class="menu-name-cell" :style="{ paddingLeft: `${record.level * 22}px` }">
|
|
|
+ <button
|
|
|
+ v-if="hasChildren(record)"
|
|
|
+ class="tree-toggle"
|
|
|
+ type="button"
|
|
|
+ @click="toggleMenu(record.id)"
|
|
|
+ >
|
|
|
+ {{ expandedIds.has(record.id) ? '-' : '+' }}
|
|
|
+ </button>
|
|
|
+ <span v-else class="tree-toggle-placeholder"></span>
|
|
|
+ <span>{{ record.title }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="路径" data-index="path" />
|
|
|
+ <a-table-column title="组件" data-index="component" />
|
|
|
+ <a-table-column title="权限标识" data-index="permission" />
|
|
|
+ <a-table-column title="操作" :width="190" align="center">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <button class="text-action" type="button" @click="edit(record)">修改</button>
|
|
|
+ <a-divider direction="vertical" />
|
|
|
+ <button
|
|
|
+ v-if="canCreateChild(record)"
|
|
|
+ class="text-action"
|
|
|
+ type="button"
|
|
|
+ @click="openCreate(record)"
|
|
|
+ >
|
|
|
+ 新增
|
|
|
+ </button>
|
|
|
+ <a-divider v-if="canCreateChild(record)" direction="vertical" />
|
|
|
+ <button class="text-action danger" type="button" @click="remove(record.id)">删除</button>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ </template>
|
|
|
+ </a-table>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <FormDialog v-model="dialogVisible" :title="form.id ? '修改菜单' : '新增菜单'" @submit="save">
|
|
|
+ <a-form :model="form" layout="vertical" class="drawer-form-inner">
|
|
|
+ <a-form-item label="菜单名称" required>
|
|
|
+ <a-input v-model="form.menuName" placeholder="请输入菜单名称" />
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="上级菜单">
|
|
|
+ <a-input :model-value="form.parentName" readonly />
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="路由路径" required>
|
|
|
+ <a-input v-model="form.path" placeholder="/system/example" />
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="组件标识">
|
|
|
+ <a-input v-model="form.component" placeholder="system/ExampleList" />
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="路由名称">
|
|
|
+ <a-input v-model="form.routeName" placeholder="ExampleList" />
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="权限标识">
|
|
|
+ <a-input v-model="form.permission" placeholder="system:example:list" />
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="类型" required>
|
|
|
+ <a-select v-model="form.menuType">
|
|
|
+ <a-option :value="1">目录</a-option>
|
|
|
+ <a-option :value="2">菜单</a-option>
|
|
|
+ </a-select>
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="排序" required>
|
|
|
+ <a-input-number v-model="form.sort" :min="0" :precision="0" />
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="状态">
|
|
|
+ <a-radio-group v-model="form.status" type="button">
|
|
|
+ <a-radio :value="1">正常</a-radio>
|
|
|
+ <a-radio :value="0">停用</a-radio>
|
|
|
+ </a-radio-group>
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="显示">
|
|
|
+ <a-switch v-model="visible" checked-text="显示" unchecked-text="隐藏" />
|
|
|
+ </a-form-item>
|
|
|
+ </a-form>
|
|
|
+ </FormDialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
+import { Message, Modal } from '@arco-design/web-vue'
|
|
|
+import FormDialog from '../../components/FormDialog.vue'
|
|
|
+import { menuApi } from '../../api/system'
|
|
|
+
|
|
|
+const menus = ref([])
|
|
|
+const expandedIds = ref(new Set())
|
|
|
+const dialogVisible = ref(false)
|
|
|
+const filters = reactive({ menuName: '', permission: '', status: undefined })
|
|
|
+const activeFilters = reactive({ menuName: '', permission: '', status: undefined })
|
|
|
+const form = reactive({
|
|
|
+ id: null,
|
|
|
+ parentId: 0,
|
|
|
+ parentName: '顶级菜单',
|
|
|
+ menuName: '',
|
|
|
+ path: '',
|
|
|
+ component: '',
|
|
|
+ routeName: '',
|
|
|
+ icon: 'Menu',
|
|
|
+ permission: '',
|
|
|
+ menuType: 2,
|
|
|
+ sort: 0,
|
|
|
+ hidden: 0,
|
|
|
+ status: 1
|
|
|
+})
|
|
|
+const visible = computed({
|
|
|
+ get: () => form.hidden === 0,
|
|
|
+ set: (value) => { form.hidden = value ? 0 : 1 }
|
|
|
+})
|
|
|
+const hasActiveFilter = computed(() => Boolean(
|
|
|
+ activeFilters.menuName.trim() ||
|
|
|
+ activeFilters.permission.trim() ||
|
|
|
+ activeFilters.status !== undefined
|
|
|
+))
|
|
|
+const allMenuIdsWithChildren = computed(() => collectExpandableIds(menus.value))
|
|
|
+const allExpanded = computed(() => allMenuIdsWithChildren.value.length > 0 && allMenuIdsWithChildren.value.every((id) => expandedIds.value.has(id)))
|
|
|
+const filteredMenus = computed(() => {
|
|
|
+ if (hasActiveFilter.value) {
|
|
|
+ return flattenFiltered(menus.value)
|
|
|
+ }
|
|
|
+ return flattenVisible(menus.value)
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(load)
|
|
|
+
|
|
|
+async function load() {
|
|
|
+ menus.value = await menuApi.tree()
|
|
|
+ if (!expandedIds.value.size) {
|
|
|
+ expandedIds.value = new Set(collectExpandableIds(menus.value))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function applyFilters() {
|
|
|
+ Object.assign(activeFilters, filters)
|
|
|
+}
|
|
|
+
|
|
|
+function resetFilters() {
|
|
|
+ Object.assign(filters, { menuName: '', permission: '', status: undefined })
|
|
|
+ applyFilters()
|
|
|
+}
|
|
|
+
|
|
|
+function openCreate(parentMenu = null) {
|
|
|
+ const isChildOfFirstLevel = parentMenu?.level === 0
|
|
|
+ Object.assign(form, {
|
|
|
+ id: null,
|
|
|
+ parentId: parentMenu?.id ?? 0,
|
|
|
+ parentName: parentMenu?.title ?? '顶级菜单',
|
|
|
+ menuName: '',
|
|
|
+ path: '',
|
|
|
+ component: '',
|
|
|
+ routeName: '',
|
|
|
+ icon: 'Menu',
|
|
|
+ permission: '',
|
|
|
+ menuType: parentMenu ? (isChildOfFirstLevel ? 1 : 2) : 1,
|
|
|
+ sort: filteredMenus.value.length + 1,
|
|
|
+ hidden: 0,
|
|
|
+ status: 1
|
|
|
+ })
|
|
|
+ dialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function canCreateChild(menu) {
|
|
|
+ return menu.level < 2
|
|
|
+}
|
|
|
+
|
|
|
+function hasChildren(menu) {
|
|
|
+ return menu.hasChildren
|
|
|
+}
|
|
|
+
|
|
|
+function toggleMenu(id) {
|
|
|
+ const nextIds = new Set(expandedIds.value)
|
|
|
+ if (nextIds.has(id)) {
|
|
|
+ nextIds.delete(id)
|
|
|
+ } else {
|
|
|
+ nextIds.add(id)
|
|
|
+ }
|
|
|
+ expandedIds.value = nextIds
|
|
|
+}
|
|
|
+
|
|
|
+function toggleAllMenus() {
|
|
|
+ expandedIds.value = allExpanded.value ? new Set() : new Set(allMenuIdsWithChildren.value)
|
|
|
+}
|
|
|
+
|
|
|
+function edit(menu) {
|
|
|
+ Object.assign(form, {
|
|
|
+ id: menu.id,
|
|
|
+ parentId: menu.parentId,
|
|
|
+ parentName: getParentMenuName(menu.parentId),
|
|
|
+ menuName: menu.title,
|
|
|
+ path: menu.path,
|
|
|
+ component: menu.component,
|
|
|
+ routeName: menu.name,
|
|
|
+ icon: menu.icon,
|
|
|
+ permission: menu.permission,
|
|
|
+ menuType: menu.menuType,
|
|
|
+ sort: menu.sort,
|
|
|
+ hidden: menu.hidden ? 1 : 0,
|
|
|
+ status: menu.status ?? 1
|
|
|
+ })
|
|
|
+ dialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+async function save() {
|
|
|
+ if (!form.menuName || !form.path) {
|
|
|
+ Message.warning('请填写菜单名称和路由路径')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const { parentName, ...payload } = form
|
|
|
+ await menuApi.save({ ...payload })
|
|
|
+ Message.success(form.id ? '修改成功' : '新增成功')
|
|
|
+ await load()
|
|
|
+ dialogVisible.value = false
|
|
|
+}
|
|
|
+
|
|
|
+function remove(id) {
|
|
|
+ Modal.confirm({
|
|
|
+ title: '确认删除该菜单吗?',
|
|
|
+ content: '删除后不可恢复。',
|
|
|
+ onOk: async () => {
|
|
|
+ await menuApi.remove(id)
|
|
|
+ Message.success('删除成功')
|
|
|
+ await load()
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function flattenVisible(nodes, level = 0) {
|
|
|
+ return nodes.flatMap((node) => {
|
|
|
+ const current = toTableRow(node, level)
|
|
|
+ if (!expandedIds.value.has(node.id)) {
|
|
|
+ return [current]
|
|
|
+ }
|
|
|
+ return [current, ...flattenVisible(node.children || [], level + 1)]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function flattenFiltered(nodes, level = 0) {
|
|
|
+ return nodes.flatMap((node) => {
|
|
|
+ const children = node.children || []
|
|
|
+ const childRows = flattenFiltered(children, level + 1)
|
|
|
+ if (!matchesFilters(node) && !childRows.length) {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ return [toTableRow(node, level), ...childRows]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function toTableRow(node, level) {
|
|
|
+ const { children, ...row } = node
|
|
|
+ return {
|
|
|
+ ...row,
|
|
|
+ level,
|
|
|
+ hasChildren: Array.isArray(children) && children.length > 0
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function matchesFilters(menu) {
|
|
|
+ const menuName = activeFilters.menuName.trim()
|
|
|
+ const permission = activeFilters.permission.trim()
|
|
|
+ const matchName = !menuName || menu.title?.includes(menuName)
|
|
|
+ const matchPermission = !permission || menu.permission?.includes(permission)
|
|
|
+ const matchStatus = activeFilters.status === undefined || menu.status === activeFilters.status
|
|
|
+ return matchName && matchPermission && matchStatus
|
|
|
+}
|
|
|
+
|
|
|
+function getParentMenuName(parentId) {
|
|
|
+ if (!parentId) {
|
|
|
+ return '顶级菜单'
|
|
|
+ }
|
|
|
+ const parent = findMenuById(menus.value, parentId)
|
|
|
+ return parent?.title ?? `菜单ID:${parentId}`
|
|
|
+}
|
|
|
+
|
|
|
+function findMenuById(nodes, id) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.id === id) {
|
|
|
+ return node
|
|
|
+ }
|
|
|
+ const match = findMenuById(node.children || [], id)
|
|
|
+ if (match) {
|
|
|
+ return match
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+function collectExpandableIds(nodes) {
|
|
|
+ return nodes.flatMap((node) => {
|
|
|
+ const children = node.children || []
|
|
|
+ if (!children.length) {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ return [node.id, ...collectExpandableIds(children)]
|
|
|
+ })
|
|
|
+}
|
|
|
+</script>
|