feat(系统设置): 按原型重做菜单资源设置并支持折叠控制

This commit is contained in:
yelan
2026-09-25 08:47:15 +08:00
parent 4fbca4f1a3
commit 3542c77311
@@ -1,4 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 菜单设置(原型 V10.2 `#setMenuCard`)。
*
* 版式照原型:一张 card(标题 + 右侧说明)里放 `.menuset` 两栏 ——
* 左 340px `.ms-tree`(`.ms-ops` 四个按钮 + 可滚动的 `.ms-node` 行列表),
* 右 `.ms-detail`(`.ch` 标题 + `.tbl` 事实表 + 底部按钮行)。
*
* 与原型的两处**有意差异**(都是沿用仓库既有口径,没跟着原型改):
* 1. 树默认**全部展开**(原型默认展开目录、折叠菜单)。演示时更直观,且与改版前一致。
* 2. 资源树的层级是「目录 / 菜单 / 按钮」三层,但**按钮层不允许再新增下级**
* (原型用类型推导限制,这里同样只对 DIR / MENU 开放「新增下级」)。
*
* 功能逻辑(接口、权限闸、保护节点、校验)全部沿用改版前实现,本次只动样式与布局。
*/
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@@ -22,8 +36,8 @@ const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const tree = ref<PermissionResourceNodeDto[]>([]) const tree = ref<PermissionResourceNodeDto[]>([])
const selectedId = ref('') const selectedId = ref('')
const expanded = ref(true) /** 折叠的节点 id;空数组 = 全部展开(默认) */
const treeKey = ref(0) const collapsedIds = ref<string[]>([])
const editorVisible = ref(false) const editorVisible = ref(false)
const editingId = ref('') const editingId = ref('')
const form = ref({ const form = ref({
@@ -56,6 +70,40 @@ const formParentName = computed(() => {
const editorTitle = computed( const editorTitle = computed(
() => `${editingId.value ? '编辑' : '新增'}${typeName(form.value.resourceType)}`, () => `${editingId.value ? '编辑' : '新增'}${typeName(form.value.resourceType)}`,
) )
/** 详情标题右侧的面包屑:目录:X → 菜单:Y → 按钮:Z */
const currentPath = computed(() => {
const node = currentNode.value
if (!node) return ''
const all = flatten(tree.value)
const parts: string[] = []
let cursor: PermissionResourceNodeDto | null = node
while (cursor) {
parts.unshift(`${typeName(cursor.resourceType)}:${cursor.name}`)
const parentId: string | null = cursor.parentId
cursor = parentId ? (all.find((item) => item.id === parentId) ?? null) : null
}
return parts.join(' → ')
})
interface TreeRow {
node: PermissionResourceNodeDto
level: number
hasChildren: boolean
}
/** 按折叠状态把树拍平成可见行(保持接口返回的原始顺序,不重排) */
const treeRows = computed<TreeRow[]>(() => {
const rows: TreeRow[] = []
const walk = (nodes: PermissionResourceNodeDto[], level: number) => {
nodes.forEach((node) => {
const children = node.children ?? []
rows.push({ node, level, hasChildren: children.length > 0 })
if (children.length && isOpen(node.id)) walk(children, level + 1)
})
}
walk(tree.value, 0)
return rows
})
function flatten(nodes: PermissionResourceNodeDto[]): PermissionResourceNodeDto[] { function flatten(nodes: PermissionResourceNodeDto[]): PermissionResourceNodeDto[] {
return nodes.flatMap((node) => [node, ...flatten(node.children ?? [])]) return nodes.flatMap((node) => [node, ...flatten(node.children ?? [])])
@@ -72,6 +120,33 @@ function typeName(type: PermissionResourceNodeDto['resourceType']) {
return type === 'DIR' ? '目录' : type === 'MENU' ? '菜单' : '按钮' return type === 'DIR' ? '目录' : type === 'MENU' ? '菜单' : '按钮'
} }
/** 原型徽标类名是 ty-dir / ty-menu / ty-btn(注意 BUTTON → btn,不是 button) */
function typeClass(type: PermissionResourceNodeDto['resourceType']) {
return type === 'DIR' ? 'ty-dir' : type === 'MENU' ? 'ty-menu' : 'ty-btn'
}
/* ---------------- 展开 / 折叠 ---------------- */
function isOpen(id: string) {
return !collapsedIds.value.includes(id)
}
function toggleOpen(id: string) {
collapsedIds.value = isOpen(id)
? [...collapsedIds.value, id]
: collapsedIds.value.filter((item) => item !== id)
}
function expandAll() {
collapsedIds.value = []
}
function collapseAll() {
collapsedIds.value = flatten(tree.value)
.filter((node) => (node.children ?? []).length > 0)
.map((node) => node.id)
}
function selectNode(node: PermissionResourceNodeDto) { function selectNode(node: PermissionResourceNodeDto) {
selectedId.value = node.id selectedId.value = node.id
} }
@@ -84,7 +159,6 @@ async function loadTree() {
if (!flatten(tree.value).some((node) => node.id === selectedId.value)) { if (!flatten(tree.value).some((node) => node.id === selectedId.value)) {
selectedId.value = tree.value[0]?.id ?? '' selectedId.value = tree.value[0]?.id ?? ''
} }
treeKey.value += 1
} catch { } catch {
ElMessage.error('菜单资源加载失败,请检查菜单配置权限') ElMessage.error('菜单资源加载失败,请检查菜单配置权限')
} finally { } finally {
@@ -282,31 +356,33 @@ async function moveNode(node: PermissionResourceNodeDto, direction: -1 | 1) {
} }
} }
function toggleExpansion() {
expanded.value = !expanded.value
treeKey.value += 1
}
onMounted(loadTree) onMounted(loadTree)
</script> </script>
<template> <template>
<section class="menu-settings"> <section class="menu-settings card">
<header class="menu-toolbar"> <div class="ch">
<div> <b>菜单设置</b>
<strong>菜单资源</strong> <span class="sp"></span>
<p>菜单资源同时作为导航、路由和角色授权的唯一来源。</p> <span class="sm mut"
</div> >定义系统有哪些目录 / 菜单 / 按钮权限;「角色权限」负责把这些权限分配给角色</span
<div class="toolbar-actions">
<el-button :disabled="!canManage" @click="toggleExpansion">{{
expanded ? '全部收起' : '全部展开'
}}</el-button>
<el-button type="primary" :disabled="!canManage" @click="openCreate('DIR', null)"
>新增目录</el-button
> >
<el-button </div>
type="primary"
plain <div v-loading="loading" class="menuset">
<div class="ms-tree">
<div class="ms-ops">
<button
type="button"
class="btn gh sm"
:disabled="!canManage"
@click="openCreate('DIR', null)"
>
+ 新增目录
</button>
<button
type="button"
class="btn gh sm"
:disabled=" :disabled="
!canManage || !canManage ||
!currentNode || !currentNode ||
@@ -317,138 +393,176 @@ onMounted(loadTree)
currentNode && currentNode &&
openCreate(currentNode.resourceType === 'DIR' ? 'MENU' : 'BUTTON', currentNode.id) openCreate(currentNode.resourceType === 'DIR' ? 'MENU' : 'BUTTON', currentNode.id)
" "
>新增下级</el-button
> >
+ 新增下级
</button>
<button type="button" class="btn gh sm" @click="expandAll">展开全部</button>
<button type="button" class="btn gh sm" @click="collapseAll">收起全部</button>
</div> </div>
</header>
<div v-loading="loading" class="resource-workspace"> <div class="ms-tree-box">
<aside class="resource-tree-card"> <div
<el-tree v-for="row in treeRows"
:key="treeKey" :key="row.node.id"
:data="tree" class="ms-node"
:props="{ children: 'children', label: 'name' }" :class="[
node-key="id" `lv${row.level}`,
:default-expand-all="expanded" { on: selectedId === row.node.id, off: isEffectivelyDisabled(row.node) },
:expand-on-click-node="false" ]"
highlight-current @click="selectNode(row.node)"
:current-node-key="selectedId"
@node-click="selectNode"
> >
<template #default="{ data }"> <span class="ar" @click.stop="row.hasChildren && toggleOpen(row.node.id)">{{
<span class="tree-node"> row.hasChildren ? (isOpen(row.node.id) ? '▾' : '▸') : ''
<span class="type-icon" :class="`type-${data.resourceType.toLowerCase()}`">{{
data.resourceType === 'DIR' ? '目' : data.resourceType === 'MENU' ? '页' : '钮'
}}</span> }}</span>
<span class="tree-name">{{ data.name }}</span> <span class="ty" :class="typeClass(row.node.resourceType)">
<small>#{{ data.sortNo }}</small> {{ typeName(row.node.resourceType) }}
<em :class="data.status === 'ENABLED' ? 'enabled' : 'disabled'">{{
data.status === 'ENABLED' ? '启用' : '停用'
}}</em>
</span> </span>
</template> <span class="nm"
</el-tree> >{{ row.node.icon ? `${row.node.icon} ` : '' }}{{ row.node.name }}</span
<div v-if="!loading && !tree.length" class="tree-empty">暂无菜单资源</div> >
</aside> <span class="sp"></span>
<span class="sm mut">{{ row.node.sortNo }}</span>
<section v-if="currentNode" class="resource-detail-card"> <span v-if="row.node.status === 'DISABLED'" class="ms-off-tag">停用</span>
<header class="detail-heading">
<div>
<span class="detail-type">{{ typeName(currentNode.resourceType) }}</span>
<h2>{{ currentNode.name }}</h2>
</div> </div>
<div class="toolbar-actions"> <div v-if="!treeRows.length && !loading" class="sm mut ms-empty">
<el-button 暂无菜单资源,请先新增目录
</div>
</div>
</div>
<div class="ms-detail">
<template v-if="currentNode">
<div class="ch">
<b>{{ currentNode.icon ? `${currentNode.icon} ` : '' }}{{ currentNode.name }}</b>
<span class="sp"></span>
<span class="sm mut">{{ currentPath }}</span>
</div>
<table class="tbl">
<tbody>
<tr>
<td class="k">资源类型</td>
<td>{{ typeName(currentNode.resourceType) }}</td>
</tr>
<tr>
<td class="k">名称</td>
<td>{{ currentNode.name }}</td>
</tr>
<tr v-if="currentNode.resourceType !== 'DIR'">
<td class="k">权限标识</td>
<td>
<span class="mono sm">{{ currentNode.code ?? '—' }}</span>
</td>
</tr>
<tr>
<td class="k">上级资源</td>
<td>{{ currentParentName }}</td>
</tr>
<tr v-if="currentNode.resourceType === 'MENU'">
<td class="k">路由</td>
<td>{{ currentNode.routePath || '—' }}</td>
</tr>
<tr v-if="currentNode.resourceType === 'MENU'">
<td class="k">组件</td>
<td>{{ currentNode.componentPath || '—' }}</td>
</tr>
<tr>
<td class="k">图标</td>
<td>{{ currentNode.icon || '—' }}</td>
</tr>
<tr v-if="currentNode.resourceType === 'MENU'">
<td class="k">是否显示</td>
<td>{{ currentNode.visible ? '显示' : '隐藏(仍可通过授权路由访问)' }}</td>
</tr>
<tr v-if="currentNode.resourceType === 'MENU'">
<td class="k">是否缓存</td>
<td>{{ currentNode.cacheable ? '缓存' : '不缓存' }}</td>
</tr>
<tr>
<td class="k">排序</td>
<td>{{ currentNode.sortNo }}</td>
</tr>
<tr>
<td class="k">状态</td>
<td>{{ currentNode.status === 'ENABLED' ? '启用' : '停用' }}</td>
</tr>
<tr>
<td class="k">核心资源</td>
<td>{{ currentNode.protectedNode ? '是(不可删除或停用)' : '否' }}</td>
</tr>
</tbody>
</table>
<div class="ms-actions">
<button
type="button"
class="btn gh sm"
:disabled="!canManage || currentNode.protectedNode" :disabled="!canManage || currentNode.protectedNode"
@click="openEdit(currentNode)" @click="openEdit(currentNode)"
>编辑</el-button
> >
<el-button 编辑
</button>
<button
v-if="currentNode.resourceType !== 'BUTTON'"
type="button"
class="btn gh sm"
:disabled="
!canManage || currentNode.protectedNode || isEffectivelyDisabled(currentNode)
"
@click="
openCreate(currentNode.resourceType === 'DIR' ? 'MENU' : 'BUTTON', currentNode.id)
"
>
新增下级
</button>
<button
type="button"
class="btn gh sm"
:disabled="!canManage"
@click="moveNode(currentNode, -1)"
>
上移
</button>
<button
type="button"
class="btn gh sm"
:disabled="!canManage"
@click="moveNode(currentNode, 1)"
>
下移
</button>
<button
type="button"
class="btn gh sm"
:disabled=" :disabled="
!canManage || !canManage ||
currentNode.protectedNode || currentNode.protectedNode ||
(currentNode.status === 'ENABLED' && hasProtectedDescendant(currentNode)) (currentNode.status === 'ENABLED' && hasProtectedDescendant(currentNode))
" "
@click="changeStatus(currentNode)" @click="changeStatus(currentNode)"
>{{ currentNode.status === 'ENABLED' ? '停用' : '启用' }}</el-button
> >
<el-button {{ currentNode.status === 'ENABLED' ? '停用' : '启用' }}
</button>
<button
type="button"
class="btn dg sm"
:disabled=" :disabled="
!canManage || !canManage ||
currentNode.protectedNode || currentNode.protectedNode ||
(currentNode.resourceType === 'DIR' && currentNode.children.length > 0) || (currentNode.resourceType === 'DIR' && currentNode.children.length > 0) ||
hasProtectedDescendant(currentNode) hasProtectedDescendant(currentNode)
" "
type="danger"
plain
@click="removeResource(currentNode)" @click="removeResource(currentNode)"
>删除</el-button
> >
删除
</button>
</div> </div>
</header> </template>
<dl class="resource-facts">
<div> <div v-else class="sm mut ms-empty-detail">
<dt>权限标识</dt> ← 请在左侧选择 目录 / 菜单 / 按钮,查看并编辑配置
<dd>{{ currentNode.code ?? '—' }}</dd>
</div> </div>
<div>
<dt>上级资源</dt>
<dd>
{{ currentParentName }}
</dd>
</div> </div>
<div>
<dt>排序</dt>
<dd>
{{ currentNode.sortNo }}
<span class="sort-actions"
><button
type="button"
:disabled="!canManage"
aria-label="上移"
@click="moveNode(currentNode, -1)"
>
↑</button
><button
type="button"
:disabled="!canManage"
aria-label="下移"
@click="moveNode(currentNode, 1)"
>
↓
</button></span
>
</dd>
</div>
<div v-if="currentNode.resourceType === 'MENU'">
<dt>路由</dt>
<dd>{{ currentNode.routePath || '—' }}</dd>
</div>
<div v-if="currentNode.resourceType === 'MENU'">
<dt>组件</dt>
<dd>{{ currentNode.componentPath || '—' }}</dd>
</div>
<div>
<dt>图标</dt>
<dd>{{ currentNode.icon || '—' }}</dd>
</div>
<div v-if="currentNode.resourceType === 'MENU'">
<dt>导航显示</dt>
<dd>{{ currentNode.visible ? '显示' : '隐藏(仍可通过授权路由访问)' }}</dd>
</div>
<div v-if="currentNode.resourceType === 'MENU'">
<dt>缓存</dt>
<dd>{{ currentNode.cacheable ? '开启' : '关闭' }}</dd>
</div>
<div>
<dt>核心资源</dt>
<dd>{{ currentNode.protectedNode ? '是(不可删除或停用)' : '否' }}</dd>
</div>
</dl>
</section>
<section v-else class="resource-detail-card empty-detail">
从左侧选择资源,查看或维护配置。
</section>
</div> </div>
<el-dialog v-model="editorVisible" :title="editorTitle" width="560px" destroy-on-close> <el-dialog v-model="editorVisible" :title="editorTitle" width="560px" destroy-on-close>
@@ -503,167 +617,283 @@ onMounted(loadTree)
</template> </template>
<style scoped> <style scoped>
.menu-settings { /* 原型 .card:本分区在原型里就是一张 card(标题 + 两栏工作区) */
min-height: 480px; .card {
padding: 16px 18px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
} }
.menu-toolbar,
.detail-heading { /* 原型 .card .ch */
display: flex; .ch {
gap: 16px;
align-items: center;
justify-content: space-between;
}
.menu-toolbar {
margin-bottom: 14px;
}
.menu-toolbar strong {
color: var(--ink);
font-size: 15px;
}
.menu-toolbar p {
margin: 5px 0 0;
color: var(--mut);
font-size: 12px;
}
.toolbar-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 10px;
align-items: center;
margin-bottom: 14px;
} }
.resource-workspace {
.ch b {
color: var(--ink);
font-size: 14.5px;
}
.ch .sp {
flex: 1;
}
.mut {
color: var(--mut);
}
.sm {
font-size: 12px;
}
/* 原型 .menuset */
.menuset {
display: grid; display: grid;
grid-template-columns: minmax(280px, 0.85fr) minmax(380px, 1.5fr); grid-template-columns: 340px 1fr;
gap: 14px; gap: 14px;
align-items: start; align-items: start;
min-height: 420px; min-height: 470px;
} }
.resource-tree-card,
.resource-detail-card { /* 原型 .ms-tree */
min-height: 420px; .ms-tree {
padding: 16px; padding: 10px;
background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--r); border-radius: 10px;
} }
.tree-node {
/* 原型 .ms-ops */
.ms-ops {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 9px;
}
/* 原型 .ms-tree #menuTreeBox */
.ms-tree-box {
max-height: 520px;
overflow: auto;
}
/* 原型 .ms-node 系列 */
.ms-node {
display: flex; display: flex;
gap: 7px; gap: 7px;
align-items: center; align-items: center;
width: 100%; padding: 6px 8px;
min-width: 0; font-size: 13px;
font-size: 12px; border-radius: 7px;
cursor: pointer;
} }
.type-icon {
display: inline-grid; .ms-node:hover {
width: 20px;
height: 20px;
color: var(--brand);
font-size: 10px;
place-items: center;
background: var(--brand-l); background: var(--brand-l);
border-radius: 5px;
} }
.type-button {
color: #a06c1c; .ms-node.on {
background: #fff4dd; color: #fff;
background: var(--brand);
} }
.tree-name {
.ms-node.on .sm,
.ms-node.on .ar {
color: #fff;
}
.ms-node.on .ty-dir {
color: #0e6e8c;
}
.ms-node.on .ty-menu {
color: #3a7bd5;
}
.ms-node.on .ty-btn {
color: #d9821f;
}
.ms-node.off {
opacity: 0.55;
}
.ms-node .ar {
flex: none;
width: 12px;
color: var(--mut);
font-size: 10px;
}
.ms-node .nm {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.tree-node small {
margin-left: auto; .ms-node .sp {
color: #98a8ae; flex: 1;
}
.ms-node.lv1 {
padding-left: 24px;
}
.ms-node.lv2 {
padding-left: 44px;
}
/* 原型 .ty 类型徽标 */
.ty {
flex: none;
padding: 1px 6px;
color: #687f8b;
font-size: 10px; font-size: 10px;
} background: #eef1f3;
.tree-node em {
padding: 1px 5px;
font-size: 9px;
font-style: normal;
border-radius: 10px;
}
.tree-node .enabled {
color: #18875d;
background: #e7f6ef;
}
.tree-node .disabled {
color: #849198;
background: #eef1f2;
}
.detail-heading {
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.detail-heading h2 {
margin: 4px 0 0;
color: var(--ink);
font-size: 16px;
}
.detail-type {
color: var(--brand);
font-size: 11px;
}
.resource-facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 18px;
margin: 0;
}
.resource-facts div {
padding: 14px 0;
border-bottom: 1px solid #eef3f5;
}
.resource-facts dt {
margin-bottom: 5px;
color: var(--mut);
font-size: 11px;
}
.resource-facts dd {
margin: 0;
color: var(--ink);
font-size: 12px;
overflow-wrap: anywhere;
}
.sort-actions {
display: inline-flex;
gap: 5px;
margin-left: 8px;
}
.sort-actions button {
width: 22px;
height: 22px;
color: var(--brand);
background: #f2f9fb;
border: 1px solid var(--line);
border-radius: 4px; border-radius: 4px;
} }
.sort-actions button:disabled {
opacity: 0.45; .ty-dir {
color: #0e6e8c;
background: #e3f0f4;
} }
.tree-empty,
.empty-detail { .ty-menu {
display: grid; color: #3a7bd5;
background: #e7effb;
}
.ty-btn {
color: #d9821f;
background: #fbf0dd;
}
/* 原型树节点上的「停用」小标 */
.ms-off-tag {
flex: none;
padding: 2px 10px;
color: #8a9aa3;
font-size: 10px;
font-weight: 700;
background: #eef1f3;
border-radius: 20px;
}
/* 原型 .ms-detail */
.ms-detail {
min-height: 470px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 10px;
}
/* 原型 .tbl */
.tbl {
width: 100%;
margin-bottom: 14px;
font-size: 13px;
border-collapse: collapse;
}
.tbl td {
padding: 9px 12px;
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
.tbl tr:hover td {
background: #f7fbfc;
}
.tbl td.k {
width: 110px;
color: var(--mut); color: var(--mut);
place-items: center;
}
.tree-empty {
padding: 50px 0;
font-size: 12px; font-size: 12px;
} }
@media (max-width: 980px) {
.resource-workspace { .mono {
grid-template-columns: 1fr; font-family: Consolas, 'Microsoft YaHei', monospace;
}
} }
@media (max-width: 640px) {
.menu-toolbar, .ms-actions {
.detail-heading { display: flex;
align-items: flex-start; flex-wrap: wrap;
flex-direction: column; gap: 8px;
} }
.resource-facts {
/* 原型 .btn / .btn.gh / .btn.dg / .btn.sm */
.btn {
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
}
.btn:hover {
background: var(--brand-d);
}
.btn.gh {
color: var(--brand);
background: #fff;
border-color: var(--brand);
}
.btn.gh:hover {
background: var(--brand-l);
}
.btn.dg {
color: var(--err);
background: #fff;
border-color: var(--err);
}
.btn.dg:hover {
background: var(--err-l);
}
.btn.sm {
padding: 4px 10px;
font-size: 12px;
border-radius: 5px;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.ms-empty {
padding: 14px;
}
.ms-empty-detail {
display: grid;
min-height: 430px;
place-items: center;
}
@media (max-width: 980px) {
.menuset {
grid-template-columns: 1fr; grid-template-columns: 1fr;
min-height: 0;
}
.ms-detail {
min-height: 0;
}
.ms-empty-detail {
min-height: 180px;
} }
} }
</style> </style>