|
|
@@ -0,0 +1,415 @@
|
|
|
+<template>
|
|
|
+ <div class="menu-page">
|
|
|
+ <!-- 搜索工具栏 -->
|
|
|
+ <el-card shadow="never" class="search-card">
|
|
|
+ <div class="toolbar">
|
|
|
+ <el-input
|
|
|
+ v-model="searchForm.name"
|
|
|
+ placeholder="请输入菜单名称"
|
|
|
+ size="small"
|
|
|
+ style="width:180px"
|
|
|
+ clearable
|
|
|
+ @clear="handleSearch"
|
|
|
+ />
|
|
|
+ <el-select v-model="searchForm.status" placeholder="请选择状态" size="small" clearable style="width:120px">
|
|
|
+ <el-option label="启用" :value="1" />
|
|
|
+ <el-option label="停用" :value="0" />
|
|
|
+ </el-select>
|
|
|
+ <el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">查询</el-button>
|
|
|
+ <el-button size="small" icon="el-icon-refresh" @click="handleReset">重置</el-button>
|
|
|
+ <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAdd(0)">新增</el-button>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 树形表格 -->
|
|
|
+ <el-card shadow="never" class="table-card">
|
|
|
+ <el-table
|
|
|
+ :key="tableKey"
|
|
|
+ v-loading="loading"
|
|
|
+ :data="displayTree"
|
|
|
+ row-key="id"
|
|
|
+ :tree-props="{ children: 'children' }"
|
|
|
+ default-expand-all
|
|
|
+ border
|
|
|
+ size="small"
|
|
|
+ style="width:100%"
|
|
|
+ >
|
|
|
+ <el-table-column label="菜单名称" min-width="180">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <i v-if="row.icon" :class="row.icon" style="margin-right:6px;color:#409EFF"></i>
|
|
|
+ <span>{{ row.name }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="图标" width="80" align="center">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <i v-if="row.icon" :class="row.icon" style="font-size:16px;color:#606266"></i>
|
|
|
+ <span v-else style="color:#c0c4cc">—</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="排序" prop="sort" width="65" align="center" />
|
|
|
+
|
|
|
+ <el-table-column label="权限标识" min-width="150" show-overflow-tooltip>
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <span style="color:#409EFF">{{ row.permission || '—' }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="路由地址" min-width="120" show-overflow-tooltip>
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <span>{{ row.path || '—' }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="组件路径" min-width="160" show-overflow-tooltip>
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <span>{{ row.component || '—' }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="类型" width="70" align="center">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <el-tag
|
|
|
+ :type="row.type === 'dir' ? '' : row.type === 'menu' ? 'success' : 'info'"
|
|
|
+ size="mini"
|
|
|
+ >{{ { dir: '目录', menu: '菜单', btn: '按钮' }[row.type] }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="状态" width="100" align="center">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <span>
|
|
|
+ <el-tag :type="row.visible ? 'success' : 'warning'" size="mini" style="margin-right:4px">
|
|
|
+ {{ row.visible ? '显示' : '隐藏' }}
|
|
|
+ </el-tag>
|
|
|
+ <el-tag :type="row.status ? 'success' : 'danger'" size="mini">
|
|
|
+ {{ row.status ? '启用' : '停用' }}
|
|
|
+ </el-tag>
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+
|
|
|
+ <el-table-column label="创建时间" prop="createdAt" min-width="155" />
|
|
|
+
|
|
|
+ <el-table-column label="操作" width="160" align="center" fixed="right">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <el-button
|
|
|
+ v-if="row.type !== 'btn'"
|
|
|
+ type="text"
|
|
|
+ size="mini"
|
|
|
+ @click="handleAdd(row.id)"
|
|
|
+ >新增</el-button>
|
|
|
+ <el-divider v-if="row.type !== 'btn'" direction="vertical" />
|
|
|
+ <el-button type="text" size="mini" @click="handleEdit(row)">修改</el-button>
|
|
|
+ <el-divider direction="vertical" />
|
|
|
+ <el-button type="text" size="mini" style="color:#F56C6C" @click="handleDelete(row)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 新增/修改 弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ :title="dialogTitle"
|
|
|
+ :visible.sync="dialogVisible"
|
|
|
+ width="560px"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ @close="resetDialog"
|
|
|
+ >
|
|
|
+ <el-form
|
|
|
+ ref="menuForm"
|
|
|
+ :model="form"
|
|
|
+ :rules="formRules"
|
|
|
+ label-width="90px"
|
|
|
+ size="small"
|
|
|
+ >
|
|
|
+ <el-form-item label="上级菜单" prop="parentId">
|
|
|
+ <el-select v-model="form.parentId" placeholder="请选择上级菜单" style="width:100%">
|
|
|
+ <el-option
|
|
|
+ v-for="opt in parentOptions"
|
|
|
+ :key="opt.id"
|
|
|
+ :label="opt.label"
|
|
|
+ :value="opt.id"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="菜单类型">
|
|
|
+ <el-radio-group v-model="form.type">
|
|
|
+ <el-radio label="dir">目录</el-radio>
|
|
|
+ <el-radio label="menu">菜单</el-radio>
|
|
|
+ <el-radio label="btn">按钮</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="菜单名称" prop="name">
|
|
|
+ <el-input v-model="form.name" placeholder="请输入菜单名称" />
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item v-if="form.type !== 'btn'" label="菜单图标">
|
|
|
+ <el-input v-model="form.icon" placeholder="如 el-icon-setting">
|
|
|
+ <i v-if="form.icon" slot="suffix" :class="form.icon" style="margin-right:8px;font-size:16px;line-height:30px"></i>
|
|
|
+ </el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="排序" prop="sort">
|
|
|
+ <el-input-number
|
|
|
+ v-model="form.sort"
|
|
|
+ :min="1"
|
|
|
+ :max="999"
|
|
|
+ controls-position="right"
|
|
|
+ style="width:100%"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="权限标识">
|
|
|
+ <el-input v-model="form.permission" placeholder="如 role:add" />
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <template v-if="form.type === 'menu'">
|
|
|
+ <el-form-item label="路由地址" prop="path">
|
|
|
+ <el-input v-model="form.path" placeholder="如 /role" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="组件路径" prop="component">
|
|
|
+ <el-input v-model="form.component" placeholder="如 views/role/index" />
|
|
|
+ </el-form-item>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-form-item v-if="form.type !== 'btn'" label="是否显示">
|
|
|
+ <el-radio-group v-model="form.visible">
|
|
|
+ <el-radio :label="1">显示</el-radio>
|
|
|
+ <el-radio :label="0">隐藏</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-radio-group v-model="form.status">
|
|
|
+ <el-radio :label="1">启用</el-radio>
|
|
|
+ <el-radio :label="0">停用</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <div slot="footer" class="dialog-footer">
|
|
|
+ <el-button size="small" @click="dialogVisible = false">取 消</el-button>
|
|
|
+ <el-button type="primary" size="small" :loading="saving" @click="handleSave">确 定</el-button>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getMenuList, saveMenu, deleteMenu } from '@/api/menu'
|
|
|
+
|
|
|
+const DEFAULT_FORM = () => ({
|
|
|
+ id: null,
|
|
|
+ parentId: 0,
|
|
|
+ name: '',
|
|
|
+ icon: '',
|
|
|
+ sort: 1,
|
|
|
+ permission: '',
|
|
|
+ path: '',
|
|
|
+ component: '',
|
|
|
+ type: 'menu',
|
|
|
+ visible: 1,
|
|
|
+ status: 1
|
|
|
+})
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'MenuManagement',
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ loading: false,
|
|
|
+ menuTree: [],
|
|
|
+ searchForm: { name: '', status: '' },
|
|
|
+ tableKey: 0,
|
|
|
+
|
|
|
+ dialogVisible: false,
|
|
|
+ saving: false,
|
|
|
+ isEdit: false,
|
|
|
+ form: DEFAULT_FORM()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ dialogTitle() {
|
|
|
+ return this.isEdit ? '修改菜单' : '新增菜单'
|
|
|
+ },
|
|
|
+
|
|
|
+ // 根据 type 显示可选的上级菜单
|
|
|
+ parentOptions() {
|
|
|
+ const opts = []
|
|
|
+ if (this.form.type === 'dir') {
|
|
|
+ opts.push({ id: 0, label: '顶级菜单' })
|
|
|
+ }
|
|
|
+ const traverse = (nodes, indent) => {
|
|
|
+ nodes.forEach(node => {
|
|
|
+ const prefix = ' '.repeat(indent)
|
|
|
+ if (node.type === 'dir') {
|
|
|
+ if (this.form.type === 'menu') opts.push({ id: node.id, label: prefix + node.name })
|
|
|
+ // dir can also be parent of dir
|
|
|
+ if (this.form.type === 'dir' && indent > 0) opts.push({ id: node.id, label: prefix + node.name })
|
|
|
+ }
|
|
|
+ if (node.type === 'menu' && this.form.type === 'btn') {
|
|
|
+ opts.push({ id: node.id, label: prefix + node.name })
|
|
|
+ }
|
|
|
+ if (node.children) traverse(node.children, indent + 1)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ traverse(this.menuTree, 1)
|
|
|
+ return opts
|
|
|
+ },
|
|
|
+
|
|
|
+ formRules() {
|
|
|
+ const rules = {
|
|
|
+ name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
|
|
|
+ sort: [{ required: true, message: '请输入排序', trigger: 'blur' }],
|
|
|
+ parentId: [{ required: true, message: '请选择上级菜单', trigger: 'change' }]
|
|
|
+ }
|
|
|
+ if (this.form.type === 'menu') {
|
|
|
+ rules.path = [{ required: true, message: '请输入路由地址', trigger: 'blur' }]
|
|
|
+ rules.component = [{ required: true, message: '请输入组件路径', trigger: 'blur' }]
|
|
|
+ }
|
|
|
+ return rules
|
|
|
+ },
|
|
|
+
|
|
|
+ displayTree() {
|
|
|
+ const { name, status } = this.searchForm
|
|
|
+ if (!name && status === '') return this.menuTree
|
|
|
+ return this.filterNodes(this.menuTree, name, status)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created() {
|
|
|
+ this.fetchTree()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async fetchTree() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const res = await getMenuList()
|
|
|
+ this.menuTree = res.data
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ filterNodes(nodes, keyword, status) {
|
|
|
+ return nodes.reduce((result, node) => {
|
|
|
+ const children = node.children ? this.filterNodes(node.children, keyword, status) : undefined
|
|
|
+ const selfMatch =
|
|
|
+ (!keyword || node.name.includes(keyword)) &&
|
|
|
+ (status === '' || node.status === status)
|
|
|
+ if (selfMatch) {
|
|
|
+ result.push({ ...node }) // show with all original children
|
|
|
+ } else if (children && children.length > 0) {
|
|
|
+ result.push({ ...node, children })
|
|
|
+ }
|
|
|
+ return result
|
|
|
+ }, [])
|
|
|
+ },
|
|
|
+
|
|
|
+ handleSearch() {
|
|
|
+ this.tableKey++
|
|
|
+ },
|
|
|
+
|
|
|
+ handleReset() {
|
|
|
+ this.searchForm = { name: '', status: '' }
|
|
|
+ this.tableKey++
|
|
|
+ },
|
|
|
+
|
|
|
+ handleAdd(parentId) {
|
|
|
+ this.isEdit = false
|
|
|
+ this.form = { ...DEFAULT_FORM(), parentId }
|
|
|
+ // Pre-select type based on parent
|
|
|
+ if (parentId !== 0) {
|
|
|
+ const parent = this.findNodeById(this.menuTree, parentId)
|
|
|
+ if (parent) {
|
|
|
+ this.form.type = parent.type === 'dir' ? 'menu' : 'btn'
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ this.form.type = 'dir'
|
|
|
+ }
|
|
|
+ this.dialogVisible = true
|
|
|
+ },
|
|
|
+
|
|
|
+ handleEdit(row) {
|
|
|
+ this.isEdit = true
|
|
|
+ this.form = { ...DEFAULT_FORM(), ...row }
|
|
|
+ this.dialogVisible = true
|
|
|
+ },
|
|
|
+
|
|
|
+ handleDelete(row) {
|
|
|
+ const hasChildren = row.children && row.children.length > 0
|
|
|
+ const msg = hasChildren
|
|
|
+ ? `「${row.name}」包含子菜单,删除后子菜单也将一并删除,是否确认?`
|
|
|
+ : `确认删除菜单「${row.name}」?`
|
|
|
+ this.$confirm(msg, '删除确认', {
|
|
|
+ confirmButtonText: '确认删除',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }).then(async () => {
|
|
|
+ await deleteMenu(row.id)
|
|
|
+ this.$message.success(`「${row.name}」已删除`)
|
|
|
+ await this.fetchTree()
|
|
|
+ }).catch(() => {})
|
|
|
+ },
|
|
|
+
|
|
|
+ handleSave() {
|
|
|
+ this.$refs.menuForm.validate(async valid => {
|
|
|
+ if (!valid) return
|
|
|
+ this.saving = true
|
|
|
+ try {
|
|
|
+ await saveMenu(this.form)
|
|
|
+ this.$message.success(this.isEdit ? '修改成功' : '新增成功')
|
|
|
+ this.dialogVisible = false
|
|
|
+ this.tableKey++
|
|
|
+ await this.fetchTree()
|
|
|
+ } finally {
|
|
|
+ this.saving = false
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ resetDialog() {
|
|
|
+ this.$refs.menuForm && this.$refs.menuForm.resetFields()
|
|
|
+ this.form = DEFAULT_FORM()
|
|
|
+ },
|
|
|
+
|
|
|
+ findNodeById(nodes, id) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.id === id) return node
|
|
|
+ if (node.children) {
|
|
|
+ const found = this.findNodeById(node.children, id)
|
|
|
+ if (found) return found
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.menu-page {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+
|
|
|
+ .search-card {
|
|
|
+ ::v-deep .el-card__body { padding: 12px 16px; }
|
|
|
+
|
|
|
+ .toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .table-card {
|
|
|
+ ::v-deep .el-table__row td {
|
|
|
+ vertical-align: middle;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|