RoleMenuAssign.vue 8.35 KB
<template>
  <div>
    <a-card title="角色菜单分配" :bordered="false" class="list-table-card">
      <div class="plan-layout dispatch-layout">
        <div class="plan-sidebar">
          <div class="plan-sidebar-header">
            <div>
              <div class="plan-sidebar-title">角色列表</div>
              <div class="plan-sidebar-subtitle">选择角色后配置可见菜单</div>
            </div>
          </div>
          <div class="plan-list">
            <button
              v-for="role in roles"
              :key="role.id"
              type="button"
              class="plan-item"
              :class="{ active: role.id === selectedRoleId }"
              @click="selectRole(role.id)"
            >
              <div class="plan-item-top">
                <span class="plan-item-name">{{ role.name }}</span>
                <a-tag>{{ role.roleScope }}</a-tag>
              </div>
              <div class="plan-item-bottom">
                <span>{{ role.code }}</span>
              </div>
            </button>
            <a-empty v-if="!roles.length" description="暂无角色" />
          </div>
        </div>

        <div class="plan-content">
          <template v-if="selectedRole">
            <div class="plan-content-top">
              <div class="soft-note-card plan-note-card">
                <strong>分配说明</strong>
                <p>这里只控制菜单可见性,不做完整接口权限。保存后用户重新登录即可看到最新菜单。</p>
              </div>

              <div class="plan-toolbar">
                <div class="plan-toolbar-meta">
                  <span class="plan-toolbar-eyebrow">当前角色</span>
                  <strong>{{ selectedRole.name }}</strong>
                </div>
                <div class="plan-toolbar-submit">
                  <span class="plan-toolbar-tip">平台角色只能分平台/通用菜单,分站角色只能分分站/通用菜单</span>
                  <a-button type="primary" class="plan-save-button" :loading="saving" @click="handleSave">保存分配</a-button>
                </div>
              </div>
            </div>

            <div class="plan-content-body">
              <a-spin :spinning="loadingTree">
                <div class="soft-note-card" style="margin-bottom: 12px">
                  <strong>当前角色范围:{{ selectedRole.roleScope }}</strong>
                  <p style="margin: 6px 0 0">可勾选节点已经按角色范围过滤;保存后目标账号重新登录即可看到最新菜单。</p>
                </div>
                <a-tree
                v-if="treeData.length"
                checkable
                block-node
                check-strictly
                :tree-data="treeData"
                :field-names="fieldNames"
                :checked-keys="checkedKeys"
                @check="handleCheck"
              >
                <template #title="node">
                  <span>{{ node.name }}</span>
                  <a-tag style="margin-left: 8px">{{ node.menuScope }}</a-tag>
                  <a-tag style="margin-left: 4px" :color="node.checked ? 'processing' : 'default'">{{ node.checked ? '已分配' : '未分配' }}</a-tag>
                </template>
              </a-tree>
                <a-empty v-else description="当前角色暂无可分配菜单" />
              </a-spin>
            </div>
          </template>

          <a-empty v-else description="请选择左侧角色" />
        </div>
      </div>
    </a-card>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { systemRoleApi } from '@/api'

interface RoleVO {
  id: number
  code: string
  name: string
  roleScope: string
}

interface MenuTreeNode {
  id: number
  name: string
  code: string
  menuScope: string
  checked: boolean
  children: MenuTreeNode[]
}

const route = useRoute()
const fieldNames = { title: 'name', key: 'id', children: 'children' }
const roles = ref<RoleVO[]>([])
const selectedRoleId = ref<number>()
const loadingTree = ref(false)
const treeData = ref<MenuTreeNode[]>([])
const checkedKeys = ref<number[]>([])
const saving = ref(false)

const selectedRole = computed(() => roles.value.find(item => item.id === selectedRoleId.value) || null)

async function loadRoles() {
  const res: any = await systemRoleApi.list()
  roles.value = Array.isArray(res?.data) ? res.data : []
  if (!roles.value.length) {
    selectedRoleId.value = undefined
    treeData.value = []
    checkedKeys.value = []
    return
  }

  const queryRoleId = Number(route.query.roleId)
  const preferredRoleId = Number.isFinite(queryRoleId) && queryRoleId > 0 ? queryRoleId : selectedRoleId.value
  const targetRole = roles.value.find(item => item.id === preferredRoleId) || roles.value[0]
  await selectRole(targetRole.id)
}

async function selectRole(roleId: number) {
  selectedRoleId.value = roleId
  loadingTree.value = true
  try {
    const res: any = await systemRoleApi.menuTree(roleId)
    treeData.value = Array.isArray(res?.data) ? res.data : []
    checkedKeys.value = collectCheckedIds(treeData.value)
  } finally {
    loadingTree.value = false
  }
}

async function handleSave() {
  if (!selectedRoleId.value) {
    message.warning('请先选择角色')
    return
  }
  saving.value = true
  try {
    await systemRoleApi.assignMenus(selectedRoleId.value, { menuIds: checkedKeys.value.map(Number) })
    message.success('保存成功')
    await selectRole(selectedRoleId.value)
  } finally {
    saving.value = false
  }
}

function handleCheck(keys: any) {
  checkedKeys.value = (Array.isArray(keys) ? keys : keys.checked).map(Number)
}

function collectCheckedIds(nodes: MenuTreeNode[]): number[] {
  const ids: number[] = []
  const walk = (items: MenuTreeNode[]) => {
    for (const item of items) {
      if (item.checked) ids.push(item.id)
      if (item.children?.length) walk(item.children)
    }
  }
  walk(nodes)
  return ids
}

watch(() => route.query.roleId, async (value) => {
  const roleId = Number(value)
  if (!Number.isFinite(roleId) || roleId < 1 || !roles.value.length) {
    return
  }
  if (roles.value.some(item => item.id === roleId) && selectedRoleId.value !== roleId) {
    await selectRole(roleId)
  }
})

loadRoles()
</script>

<style scoped>
.dispatch-layout {
  margin-top: 8px;
}

.plan-layout {
  display: grid;
  grid-template-columns: 280px minmax(0, 1fr);
  gap: 18px;
  min-height: 560px;
}

.plan-sidebar,
.plan-content {
  border-radius: 24px;
  border: 1px solid var(--line);
  background: var(--panel);
  padding: 18px;
}

.plan-content {
  display: flex;
  flex-direction: column;
  gap: 18px;
  min-width: 0;
}

.plan-content-top,
.plan-content-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.plan-sidebar-header,
.plan-toolbar,
.plan-item-top,
.plan-item-bottom {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.plan-sidebar-title,
.plan-item-name {
  color: var(--text-dark);
  font-family: var(--font-display);
}

.plan-sidebar-title {
  font-size: 16px;
  font-weight: 700;
}

.plan-sidebar-subtitle,
.plan-item-bottom,
.plan-toolbar-eyebrow,
.plan-toolbar-tip {
  color: var(--text-soft);
  font-size: 12px;
}

.plan-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 14px;
}

.plan-item {
  border: 1px solid var(--line);
  background: var(--panel-strong);
  border-radius: 18px;
  padding: 14px;
  text-align: left;
  cursor: pointer;
  color: inherit;
}

.plan-item.active {
  border-color: var(--brand);
  background: var(--panel-tint);
  box-shadow: var(--shadow-sm);
}

.plan-toolbar {
  flex-wrap: wrap;
  align-items: center;
  padding: 14px 16px;
  border: 1px solid var(--line);
  border-radius: 20px;
  background: var(--panel-strong);
  box-shadow: var(--shadow-sm);
}

.plan-toolbar-meta,
.plan-toolbar-submit {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.plan-toolbar-meta strong {
  color: var(--text-dark);
  font-size: 15px;
  line-height: 1.4;
}

.plan-toolbar-submit {
  margin-left: auto;
  align-items: flex-end;
}

.plan-save-button {
  min-width: 108px;
  height: 36px;
}

.plan-content > :deep(.ant-empty) {
  margin: auto 0;
}

:deep(.ant-tree) {
  background: transparent;
}

@media (max-width: 960px) {
  .plan-layout {
    grid-template-columns: 1fr;
  }

  .plan-toolbar-submit {
    margin-left: 0;
    align-items: flex-start;
  }
}
</style>