index.vue 12.8 KB
<script setup lang="ts">
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { MenuApi, type MenuListReqVO, type MenuSaveReqVO, type MenuVO } from '@/api/system/menu'
import { formatDateTime } from '@/utils'

/**
 * 系统管理 - 菜单管理
 *
 * 接口:/admin-api/system/menu(权限点 system:menu:*)
 *
 * 菜单是树形数据,`/list` 一次下发整棵树,所以这里**没有分页**:
 * el-table 用 row-key + children 递归渲染,分页会把树切断。
 *
 * `type`(1 目录 / 2 菜单 / 3 按钮)决定了表单里哪些字段有意义,
 * 这不是前端自己的约定,而是后端 `MenuServiceImpl` 的强校验,必须照做:
 *
 * | type | path | component | permission |
 * |------|------|-----------|------------|
 * | 1 目录 | 可留空 | 后端强制清空 | 忽略 |
 * | 2 菜单 | **必填**(MENU_PATH_REQUIRED) | **必填**(MENU_COMPONENT_REQUIRED) | 忽略 |
 * | 3 按钮 | 忽略 | 后端强制清空 | **必填**(MENU_PERMISSION_REQUIRED) |
 *
 * 另外两个后端约束也体现在界面上:
 *  - 父节点只能是「目录 / 菜单」(按钮不能有子节点)→ 上级菜单的下拉树里过滤掉按钮
 *  - 有子菜单的节点不能删(MENU_EXISTS_CHILDREN)→ 删除前先提示
 *
 * ⚠️ `visible` 在库里是 TINYINT(1/0),但 RespVO/SaveReqVO 两侧都是 **Boolean**,
 *    前端不需要做 1/0 转换。
 */
defineOptions({ name: 'SystemMenu' })

/** 菜单类型 */
const TYPE_DIR = 1
const TYPE_MENU = 2
const TYPE_BUTTON = 3

const loading = ref(false)
const list = ref<MenuVO[]>([])

const queryParams = reactive<MenuListReqVO>({
  name: ''
})

async function loadList() {
  loading.value = true
  try {
    list.value = (await MenuApi.getMenuList(queryParams)) || []
  } finally {
    loading.value = false
  }
}

function handleSearch() {
  loadList()
}

function handleReset() {
  queryParams.name = ''
  loadList()
}

// ---------------------------------------------------------------------------
// 上级菜单下拉树:只保留「目录 / 菜单」,按钮不能当父节点
// ---------------------------------------------------------------------------

function filterDirAndMenu(nodes: MenuVO[]): MenuVO[] {
  return nodes
    .filter((node) => node.type !== TYPE_BUTTON)
    .map((node) => ({ ...node, children: node.children ? filterDirAndMenu(node.children) : [] }))
}

const parentTree = computed(() => filterDirAndMenu(list.value))

// ---------------------------------------------------------------------------
// 新增 / 修改
// ---------------------------------------------------------------------------

const dialogVisible = ref(false)
const submitting = ref(false)
const formRef = ref<FormInstance>()

function emptyForm(): MenuSaveReqVO {
  return {
    parentId: 0,
    name: '',
    type: TYPE_MENU,
    sort: 0,
    status: 0,
    path: '',
    icon: '',
    component: '',
    componentName: '',
    permission: '',
    visible: true
  }
}

const form = reactive<MenuSaveReqVO>(emptyForm())

const isUpdate = computed(() => !!form.id)
const isDir = computed(() => form.type === TYPE_DIR)
const isMenu = computed(() => form.type === TYPE_MENU)
const isButton = computed(() => form.type === TYPE_BUTTON)

const rules: FormRules = {
  name: [
    { required: true, message: '请输入菜单名称', trigger: 'blur' },
    { max: 50, message: '菜单名称长度不能超过 50 个字符', trigger: 'blur' }
  ],
  type: [{ required: true, message: '请选择菜单类型', trigger: 'change' }],
  sort: [{ required: true, message: '请输入显示顺序', trigger: 'blur' }],
  status: [{ required: true, message: '请选择状态', trigger: 'change' }],
  path: [{ required: true, message: '菜单的路由地址不能为空', trigger: 'blur' }],
  component: [{ required: true, message: '菜单的组件路径不能为空', trigger: 'blur' }],
  permission: [{ required: true, message: '按钮的权限标识不能为空', trigger: 'blur' }]
}

function handleCreate(parentId = 0) {
  Object.assign(form, emptyForm(), { parentId })
  dialogVisible.value = true
  nextTick(() => formRef.value?.clearValidate())
}

async function handleUpdate(row: MenuVO) {
  const detail = await MenuApi.getMenu(row.id)
  Object.assign(form, emptyForm(), {
    id: detail.id,
    parentId: detail.parentId ?? 0,
    name: detail.name,
    type: detail.type ?? TYPE_MENU,
    sort: detail.sort ?? 0,
    status: detail.status ?? 0,
    path: detail.path || '',
    icon: detail.icon || '',
    component: detail.component || '',
    componentName: detail.componentName || '',
    permission: detail.permission || '',
    visible: detail.visible !== false
  })
  dialogVisible.value = true
  nextTick(() => formRef.value?.clearValidate())
}

async function handleSubmit() {
  try {
    await formRef.value?.validate()
  } catch {
    return
  }
  submitting.value = true
  try {
    const data: MenuSaveReqVO = { ...form }
    if (data.id) {
      await MenuApi.updateMenu(data)
    } else {
      await MenuApi.createMenu(data)
    }
    ElMessage.success(data.id ? '修改成功' : '新增成功')
    dialogVisible.value = false
    loadList()
  } finally {
    submitting.value = false
  }
}

// ---------------------------------------------------------------------------
// 删除
// ---------------------------------------------------------------------------

function doDelete(row: MenuVO) {
  ElMessageBox.confirm(`确认删除菜单「${row.name}」吗?`, '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(async () => {
      await MenuApi.deleteMenu(row.id)
      ElMessage.success('删除成功')
      loadList()
    })
    .catch(() => {})
}

function handleDelete(row: MenuVO) {
  if (row.children && row.children.length > 0) {
    ElMessage.warning('该菜单下还有子菜单,请先删除子菜单')
    return
  }
  doDelete(row)
}

async function handleExport() {
  await MenuApi.exportMenu(queryParams)
}

onMounted(loadList)
</script>

<template>
  <div class="app-container">
    <el-card shadow="never" class="mb-3">
      <el-form :model="queryParams" inline class="search-form">
        <el-form-item label="菜单名称">
          <el-input v-model="queryParams.name" placeholder="请输入菜单名称" clearable style="width: 180px" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">搜索</el-button>
          <el-button @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <el-card shadow="never">
      <div class="mb-3">
        <el-button v-hasPermi="'system:menu:create'" type="primary" @click="handleCreate(0)">新增</el-button>
        <el-button v-hasPermi="'system:menu:export'" plain @click="handleExport">导出</el-button>
        <el-button plain @click="loadList">刷新</el-button>
      </div>

      <el-table
        v-loading="loading"
        :data="list"
        row-key="id"
        default-expand-all
        border
        stripe
        size="small"
      >
        <el-table-column type="index" label="#" width="50" align="center" />
        <el-table-column prop="name" label="菜单名称" min-width="180" />
        <el-table-column label="图标" width="70" align="center">
          <template #default="{ row }">
            <Icon :icon="row.icon" :size="16" />
          </template>
        </el-table-column>
        <el-table-column label="类型" width="80" align="center">
          <template #default="{ row }">
            <DictTag dict-type="system_menu_type" :value="row.type" />
          </template>
        </el-table-column>
        <el-table-column prop="path" label="路由地址" min-width="150" />
        <el-table-column prop="permission" label="权限标识" min-width="180" show-overflow-tooltip />
        <el-table-column prop="component" label="组件路径" min-width="200" show-overflow-tooltip />
        <el-table-column prop="sort" label="排序" width="80" align="center" />
        <el-table-column label="状态" width="90" align="center">
          <template #default="{ row }">
            <DictTag dict-type="system_common_status" :value="row.status" />
          </template>
        </el-table-column>
        <el-table-column label="是否显示" width="100" align="center">
          <template #default="{ row }">
            <el-tag :type="row.visible ? 'success' : 'info'" size="small">
              {{ row.visible ? '显示' : '隐藏' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="创建时间" width="170">
          <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
        </el-table-column>
        <el-table-column label="操作" width="190" fixed="right" align="center">
          <template #default="{ row }">
            <!-- 按钮不能有子节点,后端会拒绝,所以只对目录/菜单给「新增下级」 -->
            <el-button
              v-if="row.type !== TYPE_BUTTON"
              v-hasPermi="'system:menu:create'"
              link
              type="primary"
              @click="handleCreate(row.id)"
            >
              新增下级
            </el-button>
            <el-button v-hasPermi="'system:menu:update'" link type="primary" @click="handleUpdate(row)">
              编辑
            </el-button>
            <el-button v-hasPermi="'system:menu:delete'" link type="danger" @click="handleDelete(row)">
              删除
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </el-card>

    <el-dialog v-model="dialogVisible" :title="isUpdate ? '修改菜单' : '新增菜单'" width="680px">
      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
        <el-form-item label="上级菜单" prop="parentId">
          <el-tree-select
            v-model="form.parentId"
            :data="parentTree"
            :props="{ label: 'name', children: 'children' }"
            node-key="id"
            check-strictly
            default-expand-all
            :render-after-expand="false"
            placeholder="不选则为顶级菜单"
            class="w-full"
          />
        </el-form-item>
        <el-form-item label="菜单类型" prop="type">
          <el-radio-group v-model="form.type">
            <el-radio :label="TYPE_DIR">目录</el-radio>
            <el-radio :label="TYPE_MENU">菜单</el-radio>
            <el-radio :label="TYPE_BUTTON">按钮</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="菜单名称" prop="name">
          <el-input v-model="form.name" placeholder="请输入菜单名称" maxlength="50" />
        </el-form-item>
        <!-- 按钮只承载权限标识,没有路由,隐藏 path/component/icon/visible 以免填了被后端清空 -->
        <template v-if="!isButton">
          <el-form-item label="路由地址" prop="path">
            <el-input v-model="form.path" :placeholder="isDir ? '目录可留空,如 /system' : '如 /system/user'" maxlength="200" />
          </el-form-item>
          <el-form-item v-if="isMenu" label="组件路径" prop="component">
            <el-input v-model="form.component" placeholder="相对 src/views,如 system/user/index" maxlength="255" />
          </el-form-item>
          <el-form-item v-if="isMenu" label="组件名称" prop="componentName">
            <!-- 与组件文件名 PascalCase 一致,keep-alive 按它缓存页面 -->
            <el-input v-model="form.componentName" placeholder="如 SystemUser" maxlength="255" />
          </el-form-item>
          <el-form-item label="菜单图标" prop="icon">
            <el-input v-model="form.icon" placeholder="如 ep:user" maxlength="100" />
          </el-form-item>
        </template>
        <el-form-item v-if="isButton" label="权限标识" prop="permission">
          <el-input v-model="form.permission" placeholder="如 system:user:create" maxlength="100" />
        </el-form-item>
        <el-form-item label="显示顺序" prop="sort">
          <el-input-number v-model="form.sort" :min="0" controls-position="right" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-radio-group v-model="form.status">
            <el-radio :label="0">开启</el-radio>
            <el-radio :label="1">停用</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item v-if="!isButton" label="是否显示" prop="visible">
          <el-radio-group v-model="form.visible">
            <el-radio :label="true">显示</el-radio>
            <el-radio :label="false">隐藏</el-radio>
          </el-radio-group>
        </el-form-item>
      </el-form>

      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>