Menu.vue 6.23 KB
<template>
  <el-card shadow="never" class="box">
    <div class="toolbar">
      <el-button type="primary" @click="openEdit()">新增菜单</el-button>
      <el-button @click="load">刷新</el-button>
    </div>
    <el-table
      v-loading="loading"
      :data="flatList"
      row-key="id"
      stripe
      default-expand-all
      :tree-props="{ children: 'children' }"
    >
      <el-table-column prop="name" label="名称" min-width="160" />
      <el-table-column label="类型" width="90">
        <template #default="{ row }">
          {{ typeLabel(row.type) }}
        </template>
      </el-table-column>
      <el-table-column prop="path" label="路由路径" min-width="140" />
      <el-table-column prop="perms" label="权限标识" min-width="140" />
      <el-table-column prop="sort" label="排序" width="70" />
      <el-table-column label="操作" width="160" fixed="right">
        <template #default="{ row }">
          <el-button type="primary" link @click="openEdit(row)">编辑</el-button>
          <el-button type="danger" link @click="onDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <el-dialog
      v-model="visible"
      :title="form.id ? '编辑菜单' : '新增菜单'"
      width="520px"
      align-center
      destroy-on-close
    >
      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
        <el-form-item label="上级" prop="parent_id">
          <el-tree-select
            v-model="form.parent_id"
            :data="parentOptions"
            check-strictly
            :render-after-expand="false"
            placeholder="0 为顶级"
            style="width: 100%"
          />
        </el-form-item>
        <el-form-item label="名称" prop="name">
          <el-input v-model="form.name" />
        </el-form-item>
        <el-form-item label="类型" prop="type">
          <el-radio-group v-model="form.type">
            <el-radio :value="1">目录</el-radio>
            <el-radio :value="2">菜单</el-radio>
            <el-radio :value="3">按钮</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="路径" prop="path">
          <el-input v-model="form.path" placeholder="如 /system/admin" />
        </el-form-item>
        <el-form-item label="组件" prop="component">
          <el-input v-model="form.component" placeholder="前端路由组件路径" />
        </el-form-item>
        <el-form-item label="权限标识" prop="perms">
          <el-input v-model="form.perms" />
        </el-form-item>
        <el-form-item label="图标" prop="icon">
          <el-input v-model="form.icon" placeholder="如 setting、user" />
        </el-form-item>
        <el-form-item label="排序" prop="sort">
          <el-input-number v-model="form.sort" :min="0" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-radio-group v-model="form.status">
            <el-radio :value="1">正常</el-radio>
            <el-radio :value="0">禁用</el-radio>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="visible = false">取消</el-button>
        <el-button type="primary" :loading="saving" @click="submit">保存</el-button>
      </template>
    </el-dialog>
  </el-card>
</template>

<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessageBox } from "element-plus";
import { deleteMenu, fetchMenuTree, saveMenu, type MenuNode } from "@/api/admin";

const loading = ref(false);
const tree = ref<MenuNode[]>([]);
const visible = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();

const form = reactive({
  id: 0,
  parent_id: 0,
  name: "",
  type: 2,
  path: "",
  component: "",
  perms: "",
  icon: "",
  sort: 0,
  status: 1,
});

const rules: FormRules = {
  name: [{ required: true, message: "必填", trigger: "blur" }],
};

const flatList = computed(() => tree.value);

const parentOptions = computed(() => [
  { value: 0, label: "顶级", children: toSelectTree(tree.value) },
]);

function toSelectTree(nodes: MenuNode[]): { value: number; label: string; children?: ReturnType<typeof toSelectTree> }[] {
  return nodes.map((n) => ({
    value: n.id,
    label: n.name,
    children: n.children?.length ? toSelectTree(n.children) : undefined,
  }));
}

function typeLabel(t: number): string {
  if (t === 1) return "目录";
  if (t === 2) return "菜单";
  if (t === 3) return "按钮";
  return String(t);
}

async function load() {
  loading.value = true;
  try {
    const res = await fetchMenuTree();
    tree.value = res.data.list;
  } finally {
    loading.value = false;
  }
}

function openEdit(row?: MenuNode) {
  if (row) {
    form.id = row.id;
    form.parent_id = row.parent_id;
    form.name = row.name;
    form.type = row.type;
    form.path = row.path;
    form.component = row.component;
    form.perms = row.perms;
    form.icon = row.icon;
    form.sort = row.sort;
    form.status = row.status ?? 1;
  } else {
    form.id = 0;
    form.parent_id = 0;
    form.name = "";
    form.type = 2;
    form.path = "";
    form.component = "";
    form.perms = "";
    form.icon = "";
    form.sort = 0;
    form.status = 1;
  }
  visible.value = true;
}

async function submit() {
  if (!formRef.value) return;
  await formRef.value.validate(async (ok) => {
    if (!ok) return;
    saving.value = true;
    try {
      await saveMenu({
        id: form.id,
        parent_id: form.parent_id,
        name: form.name.trim(),
        type: form.type,
        path: form.path.trim(),
        component: form.component.trim(),
        perms: form.perms.trim(),
        icon: form.icon.trim(),
        sort: form.sort,
        status: form.status,
      });
      visible.value = false;
      await load();
    } finally {
      saving.value = false;
    }
  });
}

async function onDelete(row: MenuNode) {
  await ElMessageBox.confirm(`确定删除菜单「${row.name}」?`, "提示", {
    type: "warning",
  });
  await deleteMenu(row.id);
  await load();
}

onMounted(() => void load());
</script>

<style scoped>
.box {
  border-radius: 12px;
}

.toolbar {
  margin-bottom: 16px;
  display: flex;
  gap: 12px;
}
</style>